Skip to content

Commit 5af6d19

Browse files
committed
feat: observability, security, and CI hardening
- Add Prometheus metrics middleware and /metrics endpoint - Add OpenTelemetry distributed tracing (FastAPI, SQLAlchemy, Redis) - Add structured RFC-style health check with version/uptime/component checks - Add API key management module (src/modules/api_key/) with domain ABCs - Refactor auth middleware to AuthenticationProvider abstraction (JWT + API key) - Add CSRF protection middleware with abstract CSRFService - Add secret scanning (Gitleaks) and SBOM generation (Trivy) to CI - Add container image signing (Cosign) to CI on push - Fix: remove httpx2 duplicate, rename MAX_REQUEST_SIZE_MB to BYTES - Fix: package name in pyproject.toml
1 parent c1759fe commit 5af6d19

27 files changed

Lines changed: 633 additions & 54 deletions

File tree

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ REDIS_URL=
2222
# JWT signing secret. Change this in every deployed environment.
2323
SECRET_KEY=
2424

25-
# Maximum request body size in bytes.
26-
MAX_REQUEST_SIZE_MB=5242880 #5mb
25+
# Maximum request body size in bytes (default 5 MiB).
26+
MAX_REQUEST_SIZE_BYTES=5242880
2727

2828
# JWT signing, validation, and token lifetime settings.
2929
ALGORITHM=HS256
@@ -51,6 +51,9 @@ ACCOUNT_LOCKOUT_MAX_ATTEMPTS=5
5151
ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
5252
ACCOUNT_LOCKOUT_DURATION_MINUTES=15
5353

54+
# CSRF protection toggle.
55+
CSRF_PROTECTION_ENABLED=true
56+
5457
# Logging output format for application logs.
5558
LOG_FORMAT=json
5659

.github/workflows/ci.yml

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
permissions:
1212
contents: read
1313
packages: write
14+
id-token: write
1415

1516
env:
1617
IMAGE_NAME: ghcr.io/${{ github.repository }}
@@ -25,6 +26,8 @@ jobs:
2526
steps:
2627
- name: Check out repository
2728
uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 0
2831

2932
- name: Set up Python
3033
uses: actions/setup-python@v5
@@ -45,14 +48,19 @@ jobs:
4548
- name: Install dependencies
4649
run: poetry install --with dev --no-interaction --no-ansi --no-root
4750

51+
- name: Run secret scanning
52+
uses: gitleaks/gitleaks-action@v2
53+
env:
54+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
4856
- name: Run lint
4957
run: poetry run ruff check src tests scripts
5058

5159
- name: Run tests
5260
run: poetry run pytest -q
5361

5462
docker:
55-
name: Build and publish image
63+
name: Build, sign, and publish image
5664
runs-on: ubuntu-latest
5765
needs: verify
5866

@@ -81,7 +89,8 @@ jobs:
8189
type=ref,event=tag
8290
type=sha,prefix=sha-
8391
84-
- name: Build image
92+
- name: Build and push image
93+
id: build
8594
uses: docker/build-push-action@v6
8695
with:
8796
context: .
@@ -91,3 +100,30 @@ jobs:
91100
labels: ${{ steps.meta.outputs.labels }}
92101
cache-from: type=gha
93102
cache-to: type=gha,mode=max
103+
104+
- name: Generate SBOM
105+
if: github.event_name == 'push'
106+
uses: aquasecurity/trivy-action@master
107+
with:
108+
image-ref: ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
109+
format: cyclonedx
110+
output: sbom.cyclonedx.json
111+
112+
- name: Upload SBOM
113+
if: github.event_name == 'push'
114+
uses: actions/upload-artifact@v4
115+
with:
116+
name: sbom
117+
path: sbom.cyclonedx.json
118+
119+
- name: Install cosign
120+
if: github.event_name == 'push'
121+
uses: sigstore/cosign-installer@v3
122+
123+
- name: Sign container image
124+
if: github.event_name == 'push'
125+
env:
126+
DIGEST: ${{ steps.build.outputs.digest }}
127+
run: |
128+
cosign sign --yes \
129+
"${{ env.IMAGE_NAME }}@${DIGEST}"

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ COMPOSE_FILE := docker-compose.yml
1111

1212
.DEFAULT_GOAL := help
1313

14-
.PHONY: help install run test lint lint-imports import-check security-scan check migrate seed downgrade revision db-up db-down db-logs clean
14+
.PHONY: help install run test lint lint-imports import-check security-scan sbom check migrate seed downgrade revision db-up db-down db-logs clean
1515

1616
help:
1717
@echo "[make:help] Available commands:"
@@ -22,6 +22,7 @@ help:
2222
@echo " [make:lint-imports] Enforce import boundary contracts"
2323
@echo " [make:import-check] Verify src.main imports"
2424
@echo " [make:security-scan] Run dependency vulnerability scan with pip-audit"
25+
@echo " [make:sbom] Generate CycloneDX SBOM for the project"
2526
@echo " [make:check] Run tests, lint, and import check"
2627
@echo " [make:migrate] Apply Alembic migrations"
2728
@echo " [make:seed] Seed baseline database records"
@@ -60,6 +61,10 @@ security-scan:
6061
@echo "[make:security-scan] Running dependency vulnerability scan"
6162
@PIP_CACHE_DIR=.cache/pip $(POETRY) run pip-audit --cache-dir .cache/pip-audit
6263

64+
sbom:
65+
@echo "[make:sbom] Generating CycloneDX SBOM"
66+
@$(POETRY) run cyclonedx-py
67+
6368
check: test lint lint-imports import-check
6469
@echo "[make:check] All checks completed"
6570

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ The API is currently versioned under `/api/v1`.
5151
- Health, liveness, and readiness endpoints with structured RFC-style responses.
5252
- Prometheus metrics at `/metrics`.
5353
- OpenTelemetry distributed tracing (FastAPI, SQLAlchemy, Redis instrumentations).
54+
- API key management for machine-to-machine authentication.
55+
- CSRF protection middleware (double-submit cookie pattern).
56+
- Extensible `AuthenticationProvider` abstraction (JWT + API key).
57+
- Secret scanning with Gitleaks in CI.
58+
- CycloneDX SBOM generation.
59+
- Container image signing with Cosign in CI.
5460
- API route grouping under `/api/v1`.
5561
- Async SQLAlchemy persistence.
5662
- Alembic database migrations.
@@ -240,6 +246,9 @@ GET /api/v1/permissions/?cursor=<cursor>&limit=10
240246
GET /api/v1/permissions/{permission_id}
241247
PATCH /api/v1/permissions/{permission_id}
242248
DELETE /api/v1/permissions/{permission_id}
249+
POST /api/v1/admin/api-keys/
250+
GET /api/v1/admin/api-keys/?skip=0&limit=100
251+
DELETE /api/v1/admin/api-keys/{api_key_id}
243252
GET /health
244253
GET /live
245254
GET /ready
@@ -301,7 +310,7 @@ DATABASE_POOL_TIMEOUT=30
301310
DATABASE_POOL_RECYCLE=3600
302311
REDIS_URL=
303312
SECRET_KEY=
304-
MAX_REQUEST_SIZE_MB=5242880
313+
MAX_REQUEST_SIZE_BYTES=5242880
305314
ALGORITHM=HS256
306315
JWT_ISSUER=todo-modulith-api
307316
JWT_AUDIENCE=todo-modulith-client
@@ -341,7 +350,7 @@ SEED_ADMIN_FULLNAME=System Administrator
341350
SEED_DEVELOPMENT_USERS_PASSWORD=
342351
```
343352

344-
`MAX_REQUEST_SIZE_MB` is currently interpreted as a byte count despite its name. Keep it at `5242880` for a 5 MiB limit.
353+
`MAX_REQUEST_SIZE_BYTES` controls the maximum request body size. Default is `5242880` (5 MiB).
345354

346355
For local development without Docker, use development mode and point the service URLs at local PostgreSQL and Redis instances, for example:
347356

@@ -706,8 +715,11 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
706715
| Database Migrations | Required | Implemented | Alembic is configured with migration commands in the README and Makefile. |
707716
| Dependency Injection | Required | Implemented | FastAPI dependencies wire repositories, handlers, auth, authorization, and database sessions. |
708717
| Configuration via Environment Variables | Required | Implemented | Pydantic settings read `.env` and reject the default secret key in production. |
709-
710-
### Next Implementation Checklist
718+
| CSRF Protection | Recommended | Implemented | Double-submit cookie pattern with `DoubleSubmitCSRFService`. Configurable via `CSRF_PROTECTION_ENABLED`. |
719+
| API Key Management (M2M) | Recommended | Implemented | Service account keys with SHA256 hashing. `ApiKeyRepository` ABC, `ApiKeyService` with generate/validate. Admin CRUD at `/api/v1/admin/api-keys/`. |
720+
| Secret Scanning in CI | Recommended | Implemented | Gitleaks action runs on every PR and push in the `verify` job. |
721+
| SBOM Generation | Recommended | Implemented | CycloneDX SBOM generated after Docker build using Trivy, uploaded as CI artifact. |
722+
| Container Image Signing | Recommended | Implemented | Cosign keyless signing of Docker images on push to GHCR. |
711723

712724
- [x] Fix and verify rate limit configuration wiring.
713725
- [x] Add security headers middleware.
@@ -722,6 +734,11 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
722734
- [x] Review exception responses to avoid leaking token parsing details or internal exception messages.
723735
- [ ] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
724736
- [x] Add dependency vulnerability scanning to local or CI checks, for example `pip-audit` or an equivalent Poetry-compatible scanner.
737+
- [x] Add CSRF protection middleware.
738+
- [x] Add API key management for machine-to-machine auth.
739+
- [x] Add secret scanning (Gitleaks) to CI.
740+
- [x] Add SBOM generation (CycloneDX) to CI.
741+
- [x] Add container image signing (Cosign) to CI.
725742

726743
## Known Notes
727744

alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from src.modules.authorization.infrastructure.models.user_has_role_model import (
3636
UserHasRoleModel, # noqa: F401
3737
)
38+
from src.modules.api_key.infrastructure.models import ApiKeyModel # noqa: F401
3839
from src.modules.todo.infrastructure.models.todo_model import TodoModel # noqa: F401
3940
from src.modules.user.infrastructure import models as user_models # noqa: F401
4041
from src.shared.database.model import Base

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ dependencies = [
3636

3737
[tool.poetry]
3838
packages = [
39-
{ include = "app", from = "src" }
39+
{ include = "src" }
4040
]
4141

4242
[build-system]
@@ -50,9 +50,9 @@ dev = [
5050
"ruff (>=0.15.17,<0.16.0)",
5151
"mypy (>=2.1.0,<3.0.0)",
5252
"alembic (>=1.18.4,<2.0.0)",
53-
"httpx2 (>=2.4.0,<3.0.0)",
5453
"pip-audit (>=2.10.1,<3.0.0)",
55-
"import-linter (>=2.11,<3.0)"
54+
"import-linter (>=2.11,<3.0)",
55+
"cyclonedx-bom (>=5.0.0,<6.0.0)"
5656
]
5757

5858
[tool.ruff]

src/core/bootstrap/middleware.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,28 @@
55
from src.core.middleware.audit_logging import AuditLoggingMiddleware
66
from src.core.middleware.auth import AuthenticationMiddleware
77
from src.core.middleware.csp import CSPMiddleware
8+
from src.core.middleware.csrf import CSRFMiddleware
89
from src.core.middleware.idempotency import IdempotencyMiddleware
910
from src.core.middleware.metrics import MetricsMiddleware
1011
from src.core.middleware.request_id import RequestIDMiddleware
1112
from src.core.middleware.request_size import LimitRequestSizeMiddleware
1213
from src.core.middleware.security_headers import SecurityHeadersMiddleware
1314
from src.core.middleware.structured_logging import StructuredLoggingMiddleware
15+
from src.core.security.providers import JWTAuthProvider
1416

1517
settings = get_settings()
1618

1719

20+
async def _get_api_key_service() -> ApiKeyService:
21+
async with AsyncSessionLocal() as session:
22+
repo = SQLAlchemyApiKeyRepository(session)
23+
return ApiKeyService(repo)
24+
25+
1826
def register_middleware(app: FastAPI):
1927
app.add_middleware(
2028
LimitRequestSizeMiddleware,
21-
max_upload_size=settings.MAX_REQUEST_SIZE_MB,
29+
max_upload_size=settings.MAX_REQUEST_SIZE_BYTES,
2230
)
2331
app.add_middleware(SecurityHeadersMiddleware)
2432
app.add_middleware(CSPMiddleware)
@@ -30,8 +38,12 @@ def register_middleware(app: FastAPI):
3038
allow_headers=settings.cors_allow_headers,
3139
)
3240
app.add_middleware(IdempotencyMiddleware)
41+
app.add_middleware(CSRFMiddleware)
3342
app.add_middleware(MetricsMiddleware)
3443
app.add_middleware(StructuredLoggingMiddleware)
3544
app.add_middleware(AuditLoggingMiddleware)
36-
app.add_middleware(AuthenticationMiddleware)
45+
app.add_middleware(
46+
AuthenticationMiddleware,
47+
providers=[JWTAuthProvider()],
48+
)
3749
app.add_middleware(RequestIDMiddleware)

src/core/config/setting.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ class Settings(BaseSettings):
5454
default="default-src 'self'; frame-ancestors 'none'",
5555
)
5656
IDEMPOTENCY_TTL_SECONDS: int = Field(alias="IDEMPOTENCY_TTL_SECONDS", default=86400)
57-
MAX_REQUEST_SIZE_MB: int = Field(
58-
alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
57+
MAX_REQUEST_SIZE_BYTES: int = Field(
58+
alias="MAX_REQUEST_SIZE_BYTES", default=5 * 1024 * 1024
5959
)
6060

6161
# Account lockout thresholds used to slow repeated failed login attempts.
@@ -69,6 +69,11 @@ class Settings(BaseSettings):
6969
alias="ACCOUNT_LOCKOUT_DURATION_MINUTES", default=15
7070
)
7171

72+
# CSRF protection settings.
73+
CSRF_PROTECTION_ENABLED: bool = Field(
74+
alias="CSRF_PROTECTION_ENABLED", default=True
75+
)
76+
7277
# Logging output format for application logs.
7378
LOG_FORMAT: str = Field(alias="LOG_FORMAT", default="json")
7479

src/core/middleware/auth.py

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
from fastapi import status
2-
from jose import JWTError
1+
from typing import Optional
2+
33
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
44
from starlette.requests import Request
55
from starlette.responses import JSONResponse, Response
66

7-
from src.core.security.jwt import JWTService
8-
from src.core.security.token_revocation import TokenRevocationService
9-
from src.shared.exceptions.credential_exception import InvalidCredentialsError
7+
from src.core.security.providers import (
8+
AuthenticationProvider,
9+
JWTAuthProvider,
10+
)
1011

1112
PUBLIC_PATHS = frozenset(
1213
{
1314
"/health",
1415
"/live",
1516
"/ready",
17+
"/metrics",
1618
"/docs",
1719
"/docs/",
1820
"/redoc",
@@ -26,6 +28,10 @@
2628

2729

2830
class AuthenticationMiddleware(BaseHTTPMiddleware):
31+
def __init__(self, app, providers: Optional[list[AuthenticationProvider]] = None):
32+
super().__init__(app)
33+
self._providers = providers or [JWTAuthProvider()]
34+
2935
async def dispatch(
3036
self, request: Request, call_next: RequestResponseEndpoint
3137
) -> Response:
@@ -39,42 +45,22 @@ async def dispatch(
3945
auth_header = request.headers.get("Authorization")
4046
if not auth_header or not auth_header.startswith("Bearer "):
4147
return JSONResponse(
42-
status_code=status.HTTP_401_UNAUTHORIZED,
48+
status_code=401,
4349
content={"detail": "Authorization header missing or malformed"},
4450
)
4551

4652
token = auth_header.split(" ", 1)[1]
4753

48-
try:
49-
payload = JWTService.decode_token(token)
50-
JWTService.require_token_type(payload, JWTService.ACCESS_TOKEN_TYPE)
51-
if await TokenRevocationService.is_access_token_revoked(token):
52-
return JSONResponse(
53-
status_code=status.HTTP_401_UNAUTHORIZED,
54-
content={"detail": "Token has been revoked"},
55-
)
56-
57-
user_id = payload.get("sub")
58-
if not user_id:
59-
raise ValueError("Token missing 'sub' claim")
60-
61-
request.state.user_id = user_id
62-
request.state.token_payload = payload
63-
except JWTError:
64-
return JSONResponse(
65-
status_code=status.HTTP_401_UNAUTHORIZED,
66-
content={"detail": "Invalid or expired token"},
67-
)
68-
except InvalidCredentialsError as e:
69-
return JSONResponse(
70-
status_code=status.HTTP_401_UNAUTHORIZED,
71-
content={"detail": str(e)},
72-
)
73-
except Exception:
74-
return JSONResponse(
75-
status_code=status.HTTP_401_UNAUTHORIZED,
76-
content={"detail": "Authentication failed"},
77-
)
54+
for provider in self._providers:
55+
result = await provider.authenticate(token, request)
56+
if result is not None:
57+
request.state.user_id = result["user_id"]
58+
request.state.auth_provider = result["provider"]
59+
request.state.token_payload = result.get("payload", {})
60+
response = await call_next(request)
61+
return response
7862

79-
response = await call_next(request)
80-
return response
63+
return JSONResponse(
64+
status_code=401,
65+
content={"detail": "Invalid or expired token"},
66+
)

0 commit comments

Comments
 (0)