fastapi-forge-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ import ast
2
+ from pathlib import Path
3
+
4
+
5
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
6
+
7
+
8
+ def test_generated_rbac_project_structure():
9
+ assert (PROJECT_ROOT / "app/api/v1/roles/router.py").exists()
10
+ assert (PROJECT_ROOT / "app/api/v1/permissions/router.py").exists()
11
+ assert (PROJECT_ROOT / "app/db/models/permission.py").exists()
12
+ assert (PROJECT_ROOT / "Dockerfile").exists()
13
+ assert (PROJECT_ROOT / ".github/workflows/ci.yml").exists()
14
+
15
+
16
+ def test_python_sources_compile():
17
+ for path in PROJECT_ROOT.rglob("*.py"):
18
+ source = path.read_text(encoding="utf-8")
19
+ ast.parse(source, filename=str(path))
20
+ compile(source, str(path), "exec")
@@ -0,0 +1,36 @@
1
+ import pytest
2
+
3
+ from app.core.dependencies import require_any_permission, require_permissions
4
+ from app.core.exceptions import ForbiddenException
5
+
6
+
7
+ @pytest.mark.asyncio
8
+ async def test_require_permissions_accepts_all_required_permissions():
9
+ checker = require_permissions("users:read", "users:update")
10
+ payload = {"permissions": ["users:read", "users:update"]}
11
+
12
+ assert await checker(payload=payload) == payload
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_require_permissions_rejects_missing_permission():
17
+ checker = require_permissions("users:update")
18
+
19
+ with pytest.raises(ForbiddenException):
20
+ await checker(payload={"permissions": ["users:read"]})
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_require_any_permission_accepts_one_permission():
25
+ checker = require_any_permission("admin:write", "users:read")
26
+ payload = {"permissions": ["users:read"]}
27
+
28
+ assert await checker(payload=payload) == payload
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_wildcard_bypasses_permission_checks():
33
+ checker = require_permissions("anything:write")
34
+ payload = {"permissions": ["*"]}
35
+
36
+ assert await checker(payload=payload) == payload
@@ -0,0 +1,68 @@
1
+ from app.core.security import (
2
+ create_access_token,
3
+ create_refresh_token,
4
+ decode_token,
5
+ hash_password,
6
+ hash_opaque_token,
7
+ verify_password,
8
+ )
9
+ from app.api.v1.auth.schema import TokenResponse, UserResponse
10
+
11
+
12
+ def test_password_hash_round_trip():
13
+ password = "StrongPassword123!"
14
+ hashed = hash_password(password)
15
+
16
+ assert hashed != password
17
+ assert verify_password(password, hashed)
18
+ assert not verify_password("wrong-password", hashed)
19
+
20
+
21
+ def test_access_token_round_trip():
22
+ token, _ = create_access_token(
23
+ "user-123",
24
+ {"permissions": ["users:read"], "role": "ADMIN"},
25
+ )
26
+
27
+ payload = decode_token(token, expected_type="access")
28
+
29
+ assert payload["sub"] == "user-123"
30
+ assert payload["permissions"] == ["users:read"]
31
+ assert payload["role"] == "ADMIN"
32
+ assert payload["jti"]
33
+
34
+
35
+ def test_refresh_token_round_trip():
36
+ token, _ = create_refresh_token("user-123")
37
+
38
+ payload = decode_token(token, expected_type="refresh")
39
+
40
+ assert payload["sub"] == "user-123"
41
+ assert payload["type"] == "refresh"
42
+
43
+
44
+ def test_opaque_token_hash_is_deterministic_and_one_way():
45
+ token = "secret-reset-token"
46
+
47
+ assert hash_opaque_token(token) == hash_opaque_token(token)
48
+ assert hash_opaque_token(token) != token
49
+
50
+
51
+ def test_refresh_token_is_excluded_from_serialized_login_response():
52
+ user = UserResponse(
53
+ id="user-123",
54
+ email="user@example.com",
55
+ username="user",
56
+ full_name="Example User",
57
+ is_active=True,
58
+ is_superuser=False,
59
+ is_verified=True,
60
+ )
61
+ response = TokenResponse(
62
+ access_token="access",
63
+ refresh_token="refresh-secret",
64
+ expires_in=1800,
65
+ user=user,
66
+ )
67
+
68
+ assert "refresh_token" not in response.model_dump()
@@ -0,0 +1,31 @@
1
+ FROM python:3.12-slim AS builder
2
+
3
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ PYTHONDONTWRITEBYTECODE=1
6
+
7
+ WORKDIR /build
8
+ COPY requirements.txt .
9
+ RUN python -m venv /opt/venv && /opt/venv/bin/pip install --upgrade pip && \
10
+ /opt/venv/bin/pip install -r requirements.txt
11
+
12
+ FROM python:3.12-slim AS runtime
13
+
14
+ ENV PATH="/opt/venv/bin:$PATH" \
15
+ PYTHONUNBUFFERED=1 \
16
+ PYTHONDONTWRITEBYTECODE=1
17
+
18
+ RUN groupadd --system app && useradd --system --gid app --create-home app
19
+ WORKDIR /app
20
+ COPY --from=builder /opt/venv /opt/venv
21
+ COPY --chown=app:app alembic alembic
22
+ COPY --chown=app:app alembic.ini .
23
+ COPY --chown=app:app app app
24
+ COPY --chown=app:app scripts scripts
25
+
26
+ USER app
27
+ EXPOSE 8000
28
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
29
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live', timeout=3)"
30
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers"]
31
+
@@ -0,0 +1,106 @@
1
+ # __PROJECT_NAME__
2
+
3
+ A production-oriented FastAPI service with JWT authentication, PostgreSQL, Alembic, persistent audit logs, and superuser-based administration. This variant does not include RBAC.
4
+
5
+ ## Included
6
+
7
+ - Access tokens plus cookie-only rotating refresh tokens, revocation, and sessions
8
+ - Password reset and email verification
9
+ - `is_superuser` authorization for administration endpoints
10
+ - Structured logs, request IDs, CORS, trusted hosts, rate limiting, and security headers
11
+ - Sync and async SQLAlchemy sessions with configurable pools
12
+ - Liveness, readiness, and database health endpoints
13
+ - Alembic migrations and an idempotent first-superuser seed command
14
+ - A non-root Docker image, local Compose stack, tests, Ruff, and CI
15
+
16
+ ## Requirements
17
+
18
+ Python 3.12+, PostgreSQL 14+, and optionally Docker with Compose v2.
19
+
20
+ ## Local setup
21
+
22
+ Create the PostgreSQL database configured by `.env`, then:
23
+
24
+ ```bash
25
+ python3.12 -m venv .venv
26
+ source .venv/bin/activate
27
+ python -m pip install --upgrade pip
28
+ pip install -r requirements-dev.txt
29
+ cp sample.env .env
30
+ alembic upgrade head
31
+ python scripts/seed_first_user.py \
32
+ --email admin@example.com \
33
+ --password 'ChangeMe123!' \
34
+ --full-name 'System Administrator'
35
+ uvicorn app.main:app --reload
36
+ ```
37
+
38
+ On Windows PowerShell, activate with `.venv\\Scripts\\Activate.ps1`. Development API documentation is at `http://localhost:8000/docs`.
39
+
40
+ ## Docker development
41
+
42
+ ```bash
43
+ cp sample.env .env
44
+ docker compose up --build
45
+ ```
46
+
47
+ Seed the administrator from another terminal:
48
+
49
+ ```bash
50
+ docker compose exec api python scripts/seed_first_user.py \
51
+ --email admin@example.com \
52
+ --password 'ChangeMe123!' \
53
+ --full-name 'System Administrator'
54
+ ```
55
+
56
+ The Compose stack is for development; its credentials and bind mount are not production settings.
57
+
58
+ ## Configuration
59
+
60
+ | Setting | Purpose |
61
+ | --- | --- |
62
+ | `APP_ENV` | Use `development`, `test`, or `production` |
63
+ | `SECRET_KEY` | JWT signing key; production requires a unique 32+ character value |
64
+ | `PG_*` | PostgreSQL connection and TLS mode |
65
+ | `ALLOWED_HOSTS` | Comma-separated production hostnames |
66
+ | `ALLOWED_ORIGINS` | Comma-separated browser origins |
67
+ | `USE_REDIS`, `REDIS_URL` | Shared rate limits across replicas |
68
+ | `LOG_TO_FILES`, `LOG_DIR` | Optional rotating files; stdout stays enabled |
69
+ | `MAIL_*`, `FRONTEND_URL` | Email delivery and frontend links |
70
+
71
+ Production startup rejects default/short secrets, wildcard hosts/origins, and non-TLS PostgreSQL modes.
72
+
73
+ ## Endpoints
74
+
75
+ - `/api/v1/auth`, `/users`, and `/audit-logs`
76
+ - `/api/v1/health/live`: process liveness
77
+ - `/api/v1/health/ready`: database readiness; returns `503` when unavailable
78
+
79
+ Docs and OpenAPI are disabled in production.
80
+
81
+ The refresh token is stored only in a scoped HTTP-only cookie. Call
82
+ `POST /api/v1/auth/refresh` with credentials enabled; it returns a new access token
83
+ and rotates the refresh cookie without exposing that token to browser JavaScript.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ pytest
89
+ ruff check .
90
+ ```
91
+
92
+ ## Production checklist
93
+
94
+ 1. Build an immutable image: `docker build -t project-api:1.0.0 .`
95
+ 2. Supply secrets externally; configure explicit HTTPS origins and hosts.
96
+ 3. Use managed PostgreSQL with verified TLS and Redis for multi-replica rate limits.
97
+ 4. Run `alembic upgrade head` once as a release job before new replicas.
98
+ 5. Terminate TLS at a trusted proxy and centralize stdout/stderr logs.
99
+ 6. Monitor liveness/readiness; test backups, restores, rotation, and rollback.
100
+
101
+ ```bash
102
+ docker run --rm --env-file .env project-api:1.0.0 alembic upgrade head
103
+ docker run --rm --env-file .env -p 8000:8000 project-api:1.0.0
104
+ ```
105
+
106
+ Do not run migrations concurrently from every application replica.
@@ -0,0 +1,16 @@
1
+ .git
2
+ .github
3
+ .env
4
+ .env.*
5
+ .venv
6
+ __pycache__
7
+ *.py[cod]
8
+ .pytest_cache
9
+ .ruff_cache
10
+ .coverage
11
+ htmlcov
12
+ logs
13
+ uploads
14
+ tests
15
+ README.md
16
+
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ cache: pip
19
+ - run: python -m pip install --upgrade pip
20
+ - run: pip install -r requirements-dev.txt
21
+ - run: ruff check .
22
+ - run: pytest
23
+
@@ -0,0 +1,19 @@
1
+ .env
2
+ .env.*
3
+ .venv/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .idea/
10
+ .vscode/
11
+ uploads/*
12
+ !uploads/.gitkeep
13
+
14
+ DENTICON_DATA_FLOW.md
15
+
16
+
17
+ logs/
18
+
19
+ fastapi-forge
@@ -0,0 +1 @@
1
+ Generate migrations after model changes with: alembic revision --autogenerate -m "describe change"
@@ -0,0 +1,51 @@
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from app.db.base import Base
5
+
6
+ from app.core.config import settings
7
+ from app.db.models import *
8
+
9
+ config = context.config
10
+ config.set_main_option("sqlalchemy.url", settings.postgres_url)
11
+
12
+ if config.config_file_name:
13
+ fileConfig(config.config_file_name)
14
+
15
+ target_metadata = Base.metadata
16
+
17
+
18
+ def run_migrations_offline():
19
+ context.configure(
20
+ url=settings.postgres_url,
21
+ target_metadata=target_metadata,
22
+ include_schemas=True,
23
+ literal_binds=True,
24
+ dialect_opts={"paramstyle": "named"},
25
+ )
26
+ with context.begin_transaction():
27
+ context.run_migrations()
28
+
29
+
30
+ def run_migrations_online():
31
+ from sqlalchemy import engine_from_config, pool
32
+
33
+ connectable = engine_from_config(
34
+ configuration=config.get_section(config.config_ini_section),
35
+ prefix="sqlalchemy.",
36
+ poolclass=pool.NullPool,
37
+ )
38
+ with connectable.connect() as connection:
39
+ context.configure(
40
+ connection=connection,
41
+ target_metadata=target_metadata,
42
+ include_schemas=True,
43
+ )
44
+ with context.begin_transaction():
45
+ context.run_migrations()
46
+
47
+
48
+ if context.is_offline_mode():
49
+ run_migrations_offline()
50
+ else:
51
+ run_migrations_online()
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,125 @@
1
+ """Initial authentication and audit schema without RBAC."""
2
+
3
+ from typing import Sequence
4
+
5
+ import sqlalchemy as sa
6
+ from alembic import op
7
+ from sqlalchemy.dialects import postgresql
8
+
9
+ revision: str = "2255ba4f9604"
10
+ down_revision: str | Sequence[str] | None = None
11
+ branch_labels: str | Sequence[str] | None = None
12
+ depends_on: str | Sequence[str] | None = None
13
+
14
+
15
+ def _base_columns() -> list[sa.Column]:
16
+ return [
17
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
18
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
19
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
20
+ sa.Column("is_deleted", sa.Boolean(), server_default=sa.false(), nullable=False),
21
+ sa.Column("deleted_at", sa.DateTime(timezone=True)),
22
+ sa.Column("deletion_note", sa.Text()),
23
+ ]
24
+
25
+
26
+ def upgrade() -> None:
27
+ for schema in ("auth", "app"):
28
+ op.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
29
+
30
+ op.create_table(
31
+ "users",
32
+ *_base_columns(),
33
+ sa.Column("email", sa.String(255), nullable=False),
34
+ sa.Column("username", sa.String(100), nullable=False),
35
+ sa.Column("full_name", sa.String(255), nullable=False),
36
+ sa.Column("hashed_password", sa.String(255), nullable=False),
37
+ sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
38
+ sa.Column("is_superuser", sa.Boolean(), server_default=sa.false(), nullable=False),
39
+ sa.Column("is_verified", sa.Boolean(), server_default=sa.false(), nullable=False),
40
+ sa.Column("last_login_at", sa.DateTime(timezone=True)),
41
+ sa.UniqueConstraint("email"),
42
+ sa.UniqueConstraint("username"),
43
+ schema="auth",
44
+ )
45
+
46
+ for table_name in ("email_verification_tokens", "password_reset_tokens"):
47
+ op.create_table(
48
+ table_name,
49
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
50
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
51
+ sa.Column("token", sa.String(255), unique=True, nullable=False),
52
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
53
+ sa.Column("used_at", sa.DateTime(timezone=True)),
54
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
55
+ schema="auth",
56
+ )
57
+
58
+ op.create_table(
59
+ "user_sessions",
60
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
61
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
62
+ sa.Column("refresh_jti", sa.String(64), unique=True, nullable=False),
63
+ sa.Column("previous_refresh_jti", sa.String(64)),
64
+ sa.Column("user_agent", sa.Text()),
65
+ sa.Column("ip_address", sa.String(45)),
66
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
67
+ sa.Column("revoked_at", sa.DateTime(timezone=True)),
68
+ sa.Column("last_used_at", sa.DateTime(timezone=True)),
69
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
70
+ schema="auth",
71
+ )
72
+ op.create_table(
73
+ "revoked_tokens",
74
+ *_base_columns(),
75
+ sa.Column("jti", sa.String(64), unique=True, nullable=False),
76
+ sa.Column("token_type", sa.String(20), nullable=False),
77
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
78
+ schema="auth",
79
+ )
80
+ op.create_table(
81
+ "notifications",
82
+ *_base_columns(),
83
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
84
+ sa.Column("title", sa.String(255), nullable=False),
85
+ sa.Column("body", sa.Text(), nullable=False),
86
+ sa.Column("notification_type", sa.String(100), nullable=False),
87
+ sa.Column("channel", sa.String(50), server_default="in_app", nullable=False),
88
+ sa.Column("is_read", sa.Boolean(), server_default=sa.false(), nullable=False),
89
+ sa.Column("read_at", sa.DateTime(timezone=True)),
90
+ sa.Column("payload", postgresql.JSONB()),
91
+ schema="app",
92
+ )
93
+ op.create_table(
94
+ "audit_logs",
95
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
96
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
97
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="SET NULL")),
98
+ sa.Column("user_email", sa.String(255)),
99
+ sa.Column("action", sa.String(100), nullable=False),
100
+ sa.Column("resource", sa.String(100), nullable=False),
101
+ sa.Column("resource_id", sa.String(255)),
102
+ sa.Column("old_values", postgresql.JSONB()),
103
+ sa.Column("new_values", postgresql.JSONB()),
104
+ sa.Column("log_metadata", postgresql.JSONB()),
105
+ sa.Column("ip_address", sa.String(45)),
106
+ sa.Column("user_agent", sa.Text()),
107
+ sa.Column("request_id", sa.String(100)),
108
+ schema="app",
109
+ )
110
+
111
+
112
+ def downgrade() -> None:
113
+ for table, schema in (
114
+ ("audit_logs", "app"),
115
+ ("notifications", "app"),
116
+ ("revoked_tokens", "auth"),
117
+ ("user_sessions", "auth"),
118
+ ("password_reset_tokens", "auth"),
119
+ ("email_verification_tokens", "auth"),
120
+ ("users", "auth"),
121
+ ):
122
+ op.drop_table(table, schema=schema)
123
+ op.execute("DROP SCHEMA IF EXISTS app")
124
+ op.execute("DROP SCHEMA IF EXISTS auth")
125
+
@@ -0,0 +1,35 @@
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+
5
+ [loggers]
6
+ keys = root,sqlalchemy,alembic
7
+
8
+ [handlers]
9
+ keys = console
10
+
11
+ [formatters]
12
+ keys = generic
13
+
14
+ [logger_root]
15
+ level = WARN
16
+ handlers = console
17
+
18
+ [logger_sqlalchemy]
19
+ level = WARN
20
+ handlers =
21
+ qualname = sqlalchemy.engine
22
+
23
+ [logger_alembic]
24
+ level = INFO
25
+ handlers = console
26
+ qualname = alembic
27
+
28
+ [handler_console]
29
+ class = StreamHandler
30
+ args = (sys.stderr,)
31
+ level = NOTSET
32
+ formatter = generic
33
+
34
+ [formatter_generic]
35
+ format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,29 @@
1
+ from fastapi import APIRouter
2
+
3
+ from app.api.v1.audit_logs.router import router as audit_logs_router
4
+ from app.api.v1.auth.router import router as auth_router
5
+ from app.api.v1.health.router import router as health_router
6
+ from app.api.v1.users.router import router as users_router
7
+
8
+ api_router = APIRouter(prefix="/api/v1")
9
+
10
+ API_ROUTES = (
11
+ (health_router, "/api/v1", ["Health"]),
12
+ (auth_router, "/api/v1/auth", ["Authentication"]),
13
+ (users_router, "/api/v1/users", ["Users"]),
14
+ (audit_logs_router, "/api/v1/audit-logs", ["Audit Logs"]),
15
+ )
16
+
17
+
18
+ def register_api_routes(app) -> None:
19
+ for route, prefix, tags in API_ROUTES:
20
+ app.include_router(route, prefix=prefix, tags=tags)
21
+
22
+
23
+ for route, prefix, tags in API_ROUTES:
24
+ api_router.include_router(
25
+ route, prefix=prefix.removeprefix("/api/v1"), tags=tags
26
+ )
27
+
28
+ router = api_router
29
+
@@ -0,0 +1,38 @@
1
+ from sqlalchemy import func, or_, select
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from app.db.models.audit_log import AuditLog
5
+ from app.helper.pagination_helper import apply_pagination
6
+
7
+
8
+ class AuditLogRepository:
9
+ def __init__(self, session: AsyncSession):
10
+ self.session = session
11
+
12
+ async def list(
13
+ self,
14
+ page: int,
15
+ page_size: int,
16
+ pagination: bool,
17
+ search: str | None = None,
18
+ ) -> tuple[list[AuditLog], int]:
19
+ query = select(AuditLog)
20
+ if search:
21
+ pattern = f"%{search.strip()}%"
22
+ query = query.where(
23
+ or_(
24
+ AuditLog.action.ilike(pattern),
25
+ AuditLog.resource.ilike(pattern),
26
+ AuditLog.user_email.ilike(pattern),
27
+ AuditLog.resource_id.ilike(pattern),
28
+ )
29
+ )
30
+ total = (
31
+ await self.session.execute(
32
+ select(func.count()).select_from(query.subquery())
33
+ )
34
+ ).scalar_one()
35
+ query = query.order_by(AuditLog.created_at.desc())
36
+ query = apply_pagination(query, page, page_size, pagination)
37
+ result = await self.session.execute(query)
38
+ return list(result.scalars().all()), total