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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- 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,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 @@
|
|
|
1
|
+
Generate migrations after model changes with: alembic revision --autogenerate -m "describe change"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|