Boilerplate FastAPI & Dockerfile + NGINX

This commit is contained in:
2025-09-22 17:58:16 +01:00
parent f3fbed5298
commit bf600b8e42
35 changed files with 650 additions and 0 deletions

0
app/__init__.py Normal file
View File

33
app/api/deps.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer
from jose import jwt, JWTError
from app.core.config import settings
from app.db.session import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from sqlalchemy import select
bearer = HTTPBearer()
async def get_db() -> AsyncSession:
async for s in get_session():
yield s
async def get_current_user(token=Depends(bearer), db: AsyncSession = Depends(get_db)) -> User:
try:
payload = jwt.decode(token.credentials, settings.SECRET_KEY, algorithms=["HS256"])
email: str = payload.get("sub")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
res = await db.execute(select(User).where(User.email == email))
user = res.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
def require_role(*roles: str):
async def _dep(user: User = Depends(get_current_user)):
if user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient role")
return user
return _dep

0
app/api/v1/__init__.py Normal file
View File

0
app/api/v1/access.py Normal file
View File

0
app/api/v1/audit.py Normal file
View File

25
app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db
from app.core.security import create_access_token, verify_password
from app.models.user import User
router = APIRouter()
class LoginIn(BaseModel):
email: str
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/login", response_model=TokenOut)
async def login(data: LoginIn, db: AsyncSession = Depends(get_db)):
res = await db.execute(select(User).where(User.email == data.email))
user = res.scalar_one_or_none()
if not user or not user.hashed_password or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
return TokenOut(access_token=create_access_token(user.email))

60
app/api/v1/keys.py Normal file
View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.models.sshkey import SSHKey
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from datetime import datetime, timezone
import base64
router = APIRouter()
ALLOWED_ALGOS = {"ssh-ed25519", "ecdsa-sha2-nistp256"} # expand if needed
class SSHKeyIn(BaseModel):
name: str
public_key: str
expires_at: datetime | None = None
@field_validator("public_key")
@classmethod
def validate_pubkey(cls, v: str):
# quick parse: "<algo> <b64> [comment]"
parts = v.strip().split()
if len(parts) < 2:
raise ValueError("Invalid SSH public key format")
algo, b64 = parts[0], parts[1]
if algo not in ALLOWED_ALGOS:
raise ValueError(f"Key algorithm not allowed: {algo}")
try:
base64.b64decode(b64)
except Exception:
raise ValueError("Public key is not valid base64")
return v
class SSHKeyOut(BaseModel):
id: int
name: str
algo: str
public_key: str
expires_at: datetime | None
is_active: bool
@router.post("/", response_model=SSHKeyOut)
async def add_key(data: SSHKeyIn, db: AsyncSession = Depends(get_db), user=Depends(get_current_user)):
algo = data.public_key.split()[0]
key = SSHKey(user_id=user.id, name=data.name, public_key=data.public_key, algo=algo,
expires_at=data.expires_at)
db.add(key)
await db.commit()
await db.refresh(key)
return SSHKeyOut(id=key.id, name=key.name, algo=key.algo, public_key=key.public_key,
expires_at=key.expires_at, is_active=key.is_active)
@router.get("/", response_model=list[SSHKeyOut])
async def list_keys(db: AsyncSession = Depends(get_db), user=Depends(get_current_user)):
res = await db.execute(select(SSHKey).where(SSHKey.user_id == user.id))
rows = res.scalars().all()
return [SSHKeyOut(id=k.id, name=k.name, algo=k.algo, public_key=k.public_key,
expires_at=k.expires_at, is_active=k.is_active) for k in rows]

0
app/api/v1/servers.py Normal file
View File

13
app/core/config.py Normal file
View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "Keywarden"
API_V1_STR: str = "/api/v1"
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
POSTGRES_DSN: str # e.g. postgresql+asyncpg://user:pass@db:5432/keywarden
OIDC_ISSUER: str | None = None
OIDC_CLIENT_ID: str | None = None
OIDC_CLIENT_SECRET: str | None = None
settings = Settings()

17
app/core/security.py Normal file
View File

@@ -0,0 +1,17 @@
from datetime import datetime, timedelta, timezone
from jose import jwt
from passlib.hash import argon2
from app.core.config import settings
ALGO = "HS256"
def create_access_token(sub: str, minutes: int | None = None) -> str:
expire = datetime.now(tz=timezone.utc) + timedelta(minutes=minutes or settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": sub, "exp": expire}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGO)
def verify_password(password: str, hashed: str) -> bool:
return argon2.verify(password, hashed)
def hash_password(password: str) -> str:
return argon2.hash(password)

0
app/db/__init__.py Normal file
View File

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from app.models.user import User
from app.models.server import Server
from app.models.sshkey import SSHKey
from app.models.access_request import AccessRequest
from app.models.audit import AuditEvent

9
app/db/session.py Normal file
View File

@@ -0,0 +1,9 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.core.config import settings
engine = create_async_engine(settings.POSTGRES_DSN, echo=False, future=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

12
app/main.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import FastAPI
from app.core.config import settings
from app.api.v1 import auth, keys
app = FastAPI(title=settings.PROJECT_NAME)
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
app.include_router(keys.router, prefix=f"{settings.API_V1_STR}/keys", tags=["keys"])
# Health endpoint (useful for docker, agent and uptime)
@app.get("/healthz")
def healthz():
return {"status": 'ok'}

0
app/models/__init__.py Normal file
View File

View File

@@ -0,0 +1,13 @@
from sqlalchemy import ForeignKey, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime, timezone
from app.models.user import Base
class AccessRequest(Base):
__tablename__ = "access_requests"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(16), default="requested") # requested|approved|denied|expired
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reason: Mapped[str | None] = mapped_column(String(512))

15
app/models/audit.py Normal file
View File

@@ -0,0 +1,15 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, DateTime
from datetime import datetime, timezone
from app.models.user import Base
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[int] = mapped_column(primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(tz=timezone.utc))
actor: Mapped[str] = mapped_column(String(254)) # email or system
action: Mapped[str] = mapped_column(String(64)) # "request.create", "key.add", etc.
object: Mapped[str] = mapped_column(String(64)) # "server:host123" / "user:42"
details: Mapped[str] = mapped_column(String(1024)) # summary (keep short)
prev_hash: Mapped[str | None] = mapped_column(String(128))
curr_hash: Mapped[str | None] = mapped_column(String(128))

11
app/models/server.py Normal file
View File

@@ -0,0 +1,11 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, JSON, Boolean, Integer
from app.models.user import Base
class Server(Base):
__tablename__ = "servers"
id: Mapped[int] = mapped_column(primary_key=True)
hostname: Mapped[str] = mapped_column(String(255), unique=True, index=True)
tags: Mapped[dict] = mapped_column(JSON, default=dict) # e.g. {"env":"prod","group":"db"}
managed: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[int] = mapped_column(Integer, default=0) # bump to trigger agent reconcile

14
app/models/sshkey.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy import ForeignKey, String, DateTime, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, timezone
from app.models.user import Base
class SSHKey(Base):
__tablename__ = "ssh_keys"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(80))
public_key: Mapped[str] = mapped_column(String(4096))
algo: Mapped[str] = mapped_column(String(32))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

13
app/models/user.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Boolean
from app.db.session import engine # only for Alembic discovery, not used here
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(254), unique=True, index=True)
hashed_password: Mapped[str | None] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default="user") # user|admin|auditor
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

0
app/schemas/__init__.py Normal file
View File

View File

0
app/schemas/audit.py Normal file
View File

0
app/schemas/auth.py Normal file
View File

0
app/schemas/server.py Normal file
View File

0
app/schemas/sshkey.py Normal file
View File

0
app/schemas/user.py Normal file
View File