Skip to content

Commit 7802d7b

Browse files
committed
new file: .env.example/.env.example
new file: .github/workflows/ci.yml new file: .gitignore new file: Dockerfile/Dockerfile modified: README.md new file: app/config.py new file: app/db.py new file: app/init.py new file: app/main.py new file: app/models.py new file: app/routes_auth.py new file: app/routes_notes.py new file: app/schemas.py new file: app/security.py new file: docker-compose.yml new file: pyproject.toml new file: tests/test_auth_and_notes.py
1 parent f612808 commit 7802d7b

17 files changed

Lines changed: 399 additions & 0 deletions

File tree

.env.example/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
APP_ENV=dev
2+
JWT_SECRET=change-me-to-a-long-random-secret
3+
JWT_EXPIRE_MINUTES=30
4+
FERNET_KEY=change-me-generate-with-python-fernet
5+
CORS_ORIGINS=http://localhost:3000
6+
7+
# To generate a Fernet key:
8+
9+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.11"
15+
- run: python -m pip install --upgrade pip
16+
- run: pip install -e ".[dev]"
17+
- run: ruff check app tests
18+
- run: pytest

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.venv/
2+
__pycache__/
3+
*.pyc
4+
*.db
5+
.env
6+
.pytest_cache/
7+
.coverage
8+
.DS_Store

Dockerfile/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
COPY pyproject.toml /app/pyproject.toml
5+
6+
RUN pip install --no-cache-dir --upgrade pip \
7+
&& pip install --no-cache-dir -e ".[dev]"
8+
9+
COPY app /app/app
10+
COPY tests /app/tests
11+
12+
EXPOSE 8000
13+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,39 @@
11
# secure-notes-api
22
A secure-by-design REST API for encrypted-at-rest notes, with JWT auth, password hashing, basic security headers, Docker, tests, and CI.
3+
4+
# vaultlight-secure-notes-api
5+
6+
A secure notes API built with FastAPI:
7+
- JWT authentication
8+
- Password hashing (bcrypt)
9+
- Notes encrypted at rest (Fernet)
10+
- SQLite for demo, easy to swap to Postgres
11+
- Docker + docker-compose
12+
- Tests + CI
13+
14+
## Run locally
15+
16+
```bash
17+
python -m venv .venv
18+
source .venv/bin/activate
19+
pip install -e ".[dev]"
20+
21+
cp .env.example .env
22+
uvicorn app.main:app --reload
23+
24+
Open docs:
25+
26+
http://127.0.0.1:8000/docs
27+
28+
Run with Docker
29+
cp .env.example .env
30+
docker compose up --build
31+
Security notes (intended design)
32+
33+
Passwords are hashed with bcrypt.
34+
35+
Notes are encrypted before storage using a server-side key (FERNET_KEY).
36+
37+
JWT access tokens are signed with JWT_SECRET.
38+
39+
This is a demo architecture; for production, move secrets to a vault/KMS, add refresh tokens, and use Postgres with migrations.

app/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pydantic_settings import BaseSettings
2+
3+
4+
class Settings(BaseSettings):
5+
app_env: str = "dev"
6+
jwt_secret: str
7+
jwt_expire_minutes: int = 30
8+
fernet_key: str
9+
cors_origins: str = "http://localhost:3000"
10+
11+
class Config:
12+
env_file = ".env"
13+
case_sensitive = False
14+
15+
16+
settings = Settings()

app/db.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
3+
4+
ENGINE = create_engine("sqlite:///./vaultlight.db", connect_args={"check_same_thread": False})
5+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=ENGINE)
6+
7+
8+
class Base(DeclarativeBase):
9+
pass

app/init.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# app package

app/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from fastapi import FastAPI
2+
from fastapi.middleware.cors import CORSMiddleware
3+
4+
from .config import settings
5+
from .db import ENGINE, Base
6+
from .routes_auth import router as auth_router
7+
from .routes_notes import router as notes_router
8+
9+
app = FastAPI(title="Vaultlight Secure Notes API", version="1.0.0")
10+
11+
# Basic security headers middleware
12+
@app.middleware("http")
13+
async def security_headers(request, call_next):
14+
response = await call_next(request)
15+
response.headers["X-Content-Type-Options"] = "nosniff"
16+
response.headers["X-Frame-Options"] = "DENY"
17+
response.headers["Referrer-Policy"] = "no-referrer"
18+
response.headers["Content-Security-Policy"] = "default-src 'none'"
19+
return response
20+
21+
app.add_middleware(
22+
CORSMiddleware,
23+
allow_origins=[o.strip() for o in settings.cors_origins.split(",") if o.strip()],
24+
allow_credentials=False,
25+
allow_methods=["GET", "POST", "DELETE"],
26+
allow_headers=["Authorization", "Content-Type"],
27+
)
28+
29+
app.include_router(auth_router)
30+
app.include_router(notes_router)
31+
32+
# Create tables for demo use. In production, prefer migrations.
33+
Base.metadata.create_all(bind=ENGINE)

app/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from sqlalchemy import String, Integer, LargeBinary, ForeignKey
2+
from sqlalchemy.orm import Mapped, mapped_column, relationship
3+
4+
from .db import Base
5+
6+
7+
class User(Base):
8+
__tablename__ = "users"
9+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
10+
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
11+
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
12+
13+
notes: Mapped[list["Note"]] = relationship(back_populates="owner", cascade="all, delete-orphan")
14+
15+
16+
class Note(Base):
17+
__tablename__ = "notes"
18+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
19+
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
20+
title: Mapped[str] = mapped_column(String(120), nullable=False)
21+
ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
22+
23+
owner: Mapped[User] = relationship(back_populates="notes")

0 commit comments

Comments
 (0)