Skip to content
Open
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
8 changes: 2 additions & 6 deletions auth-libs/python/src/hotosm_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
from typing import Optional

from pydantic import BaseModel, Field, HttpUrl
from pydantic import BaseModel, ConfigDict, Field, HttpUrl

from hotosm_auth.logger import get_logger

Expand Down Expand Up @@ -77,6 +77,7 @@ def _resolve_jwt_issuer() -> str:


class AuthConfig(BaseModel):
model_config = ConfigDict(frozen=True)
"""Runtime configuration for hotosm-auth."""

# Hanko configuration
Expand Down Expand Up @@ -265,8 +266,3 @@ def from_env(cls) -> "AuthConfig":
osm_api_url=osm_api_url,
admin_emails=admin_emails,
)

class Config:
"""Pydantic configuration."""

frozen = True # Make config immutable after creation
9 changes: 3 additions & 6 deletions auth-libs/python/src/hotosm_auth/schemas/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field


class MappingResponse(BaseModel):
"""Response schema for a single user mapping."""

model_config = ConfigDict(from_attributes=True)

hanko_user_id: str = Field(..., description="Hanko user UUID")
app_user_id: str = Field(..., description="Application-specific user ID")
app_name: str = Field(..., description="Application name")
Expand All @@ -21,11 +23,6 @@ class MappingResponse(BaseModel):
app_username: Optional[str] = Field(None, description="Username from app")
app_email: Optional[str] = Field(None, description="Email from app")

class Config:
"""Pydantic config for ORM attribute support."""

from_attributes = True


class MappingListResponse(BaseModel):
"""Paginated list of user mappings."""
Expand Down
5 changes: 2 additions & 3 deletions auth-libs/python/src/hotosm_auth_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# Core dependencies
# Admin functionality
# PAT resolver (re-exported from core)
from hotosm_auth.remote_resolver import remote_pat_resolver
from hotosm_auth_fastapi.admin import (
AdminUser,
require_admin,
Expand Down Expand Up @@ -36,9 +38,6 @@
# OSM routes
from hotosm_auth_fastapi.osm_routes import router as osm_router

# PAT resolver (re-exported from core)
from hotosm_auth.remote_resolver import remote_pat_resolver

# Simple setup API
from hotosm_auth_fastapi.setup import (
Auth,
Expand Down
191 changes: 124 additions & 67 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,75 +1,132 @@
# Backend Dockerfile with multi-stage build

# Development stage
FROM python:3.12-slim AS dev

WORKDIR /app

# Install system dependencies including git
RUN apt-get update && apt-get install -y \
curl \
git \
ARG PYTHON_IMG_TAG=3.12
ARG UV_IMG_TAG=0.10.6
FROM ghcr.io/astral-sh/uv:${UV_IMG_TAG} AS uv


# Base stage with shared config
FROM docker.io/python:${PYTHON_IMG_TAG}-slim-bookworm AS base
ARG APP_VERSION
ARG COMMIT_REF
ARG PYTHON_IMG_TAG
LABEL org.hotosm.login.app-name="backend" \
org.hotosm.login.app-version="${APP_VERSION}" \
org.hotosm.login.git-commit-ref="${COMMIT_REF:-none}" \
org.hotosm.login.python-img-tag="${PYTHON_IMG_TAG}" \
org.hotosm.login.maintainer="sysadmin@hotosm.org" \
org.hotosm.login.api-port="8000"
RUN apt-get update --quiet \
&& DEBIAN_FRONTEND=noninteractive \
apt-get install -y --quiet --no-install-recommends \
"locales" "ca-certificates" "curl" \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
# Set locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=never \
UV_PYTHON="python${PYTHON_IMG_TAG}" \
UV_PROJECT_ENVIRONMENT=/opt/python
STOPSIGNAL SIGINT


# Build stage with compilation dependencies
FROM base AS build
RUN apt-get update --quiet \
&& DEBIAN_FRONTEND=noninteractive \
apt-get install -y --quiet --no-install-recommends \
"build-essential" \
"gcc" \
&& rm -rf /var/lib/apt/lists/*

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install dependencies
RUN uv sync

# Copy application code
COPY . .

# Make entrypoint executable
RUN chmod +x entrypoint.sh

# Expose port
EXPOSE 8000

# Run migrations and start server
ENTRYPOINT ["./entrypoint.sh"]
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

# Production stage
FROM python:3.12-slim AS production

WORKDIR /app

# Install system dependencies including git
RUN apt-get update && apt-get install -y \
curl \
git \
COPY --from=uv /uv /usr/local/bin/uv
RUN mkdir -p /_lock
# Install third-party deps first for better cache hits
RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=/_lock/pyproject.toml \
--mount=type=bind,source=uv.lock,target=/_lock/uv.lock \
UV_CACHE_DIR=/root/.cache/uv uv sync \
--project /_lock \
--frozen \
--no-editable \
--no-dev \
--no-install-project


# Runtime stage with shared dependencies and entrypoints
FROM base AS runtime-base
ARG PYTHON_IMG_TAG
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PATH="/opt/python/bin:$PATH" \
PYTHONPATH="/opt" \
PYTHON_LIB="/opt/python/lib/python${PYTHON_IMG_TAG}/site-packages" \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
RUN apt-get update --quiet \
&& DEBIAN_FRONTEND=noninteractive \
apt-get install -y --quiet --no-install-recommends \
"curl" \
"postgresql-client" \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
# Copy Python deps from build stage
COPY --from=build /opt/python /opt/python
WORKDIR /opt
# Add non-root user
RUN useradd -u 1001 -m -c "login service account" -d /home/appuser -s /bin/false appuser \
&& mkdir -p /opt/app \
&& chown -R appuser:appuser /opt /home/appuser \
&& chmod +x /entrypoint.sh
USER appuser

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install dependencies (production only)
RUN uv sync --no-dev

# Copy application code
COPY . .

# Make entrypoint executable
RUN chmod +x entrypoint.sh

# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
# Development stage
FROM runtime-base AS dev
USER root
COPY --from=uv /uv /usr/local/bin/uv
RUN apt-get update --quiet \
&& DEBIAN_FRONTEND=noninteractive \
apt-get install -y --quiet --no-install-recommends \
"git" \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /_lock/
RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \
UV_CACHE_DIR=/root/.cache/uv uv sync \
--project /_lock \
--locked \
--no-editable \
--no-install-project \
--dev
COPY --chown=appuser:appuser app/ /opt/app/
COPY --chown=appuser:appuser alembic.ini /opt/alembic.ini
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--workers", "1", "--reload", "--reload-dir", "app", \
"--log-level", "info"]


# Expose port
EXPOSE 8000
# CI stage
FROM dev AS ci
USER root
ENTRYPOINT []
CMD ["sleep", "infinity"]

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

# Run migrations and start server
ENTRYPOINT ["./entrypoint.sh"]
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Production stage
FROM runtime-base AS prod
COPY --chown=appuser:appuser app/ /opt/app/
COPY --chown=appuser:appuser alembic.ini /opt/alembic.ini
# Note: 1 worker (process) per container, behind load balancer
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--workers", "1", "--log-level", "critical", "--no-access-log"]
# Sanity check
RUN python -V \
&& python -Im site \
&& python -c 'import app'
35 changes: 24 additions & 11 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Alembic environment configuration."""

import asyncio
from logging.config import fileConfig

from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool

from alembic import context

Expand All @@ -14,9 +16,8 @@
# this is the Alembic Config object
config = context.config

# Set database URL from settings (convert async to sync for alembic)
sync_url = settings.database_url.replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", sync_url)
# Set database URL from settings
config.set_main_option("sqlalchemy.url", settings.database_url)

# Interpret the config file for Python logging
if config.config_file_name is not None:
Expand All @@ -40,19 +41,31 @@ def run_migrations_offline() -> None:
context.run_migrations()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
def do_run_migrations(connection) -> None:
"""Run migrations with the given connection."""
context.configure(connection=connection, target_metadata=target_metadata)

with context.begin_transaction():
context.run_migrations()


async def run_async_migrations() -> None:
"""Run migrations in 'online' mode using async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)

await connectable.dispose()


with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())


if context.is_offline_mode():
Expand Down
4 changes: 1 addition & 3 deletions backend/app/api/routes/api_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,7 @@ async def resolve_token(

# Load user profile
profile_result = await db.execute(
select(UserProfile).where(
UserProfile.hanko_user_id == token_row.hanko_user_id
)
select(UserProfile).where(UserProfile.hanko_user_id == token_row.hanko_user_id)
)
profile = profile_result.scalar_one_or_none()
if not profile:
Expand Down
4 changes: 1 addition & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,11 @@ async def http_exception_handler(request: Request, exc: HTTPException):

async def _local_pat_resolver(token_hash: str, app_name: str):
"""Resolve a PAT directly from the login DB (no HTTP call to self)."""
from datetime import datetime, timezone

from hotosm_auth.models import HankoUser
from sqlalchemy import select

from app.db.database import async_session_maker
from app.db.models import UserApiToken, UserProfile
from hotosm_auth.models import HankoUser

async with async_session_maker() as session:
result = await session.execute(
Expand Down
2 changes: 1 addition & 1 deletion backend/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -e

echo "Running database migrations..."
uv run alembic upgrade head
alembic upgrade head

echo "Starting server..."
exec "$@"
2 changes: 0 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ dependencies = [
"sqlalchemy>=2.0.0",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
"psycopg2-binary>=2.9.0",
"psycopg[binary]>=3.2.0",
"hotosm-auth[fastapi]==0.2.12",
]

Expand Down
Loading