Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
53 changes: 53 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -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 **info@altsmanager.com**.
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.
68 changes: 68 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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"
```

## 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.
- 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.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
54 changes: 54 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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, or by emailing **info@altsmanager.com**.

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).
28 changes: 27 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions app/core/rate_limit.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
21 changes: 18 additions & 3 deletions app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand All @@ -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,
Expand Down
Loading
Loading