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,188 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Request, Response
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
9
|
+
from slowapi import Limiter
|
|
10
|
+
from slowapi.util import get_remote_address
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
|
|
13
|
+
from app.core.config import settings
|
|
14
|
+
from app.core.logging import get_logger, set_request_id
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# ── Rate limiter (slowapi + Redis) ───────────────────────────────
|
|
19
|
+
is_redis_store = settings.use_redis and bool(settings.redis_url)
|
|
20
|
+
limiter = Limiter(
|
|
21
|
+
key_func=get_remote_address,
|
|
22
|
+
storage_uri=settings.redis_url if is_redis_store else None,
|
|
23
|
+
default_limits=[f"{settings.rate_limit_per_minute}/minute"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Request-ID / Correlation-ID middleware ───────────────────────
|
|
28
|
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
29
|
+
"""
|
|
30
|
+
Attaches a unique X-Request-ID to every request.
|
|
31
|
+
Respects an existing header if the upstream proxy already set one.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
35
|
+
supplied_id = request.headers.get("X-Request-ID") or request.headers.get(
|
|
36
|
+
"X-Correlation-ID"
|
|
37
|
+
)
|
|
38
|
+
request_id = (
|
|
39
|
+
supplied_id
|
|
40
|
+
if supplied_id and re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", supplied_id)
|
|
41
|
+
else str(uuid.uuid4())
|
|
42
|
+
)
|
|
43
|
+
request.state.request_id = request_id
|
|
44
|
+
set_request_id(request_id)
|
|
45
|
+
|
|
46
|
+
response = await call_next(request)
|
|
47
|
+
response.headers["X-Request-ID"] = request_id
|
|
48
|
+
return response
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Structured request / response logging middleware ─────────────
|
|
52
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
53
|
+
"""
|
|
54
|
+
Logs every HTTP request and response with timing, status, and IDs.
|
|
55
|
+
Skips health-check and metrics endpoints to avoid log noise.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
SKIP_PATHS = {"/health", "/metrics", "/favicon.ico"}
|
|
59
|
+
|
|
60
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
61
|
+
if request.url.path in self.SKIP_PATHS:
|
|
62
|
+
return await call_next(request)
|
|
63
|
+
|
|
64
|
+
start = time.perf_counter()
|
|
65
|
+
request_id = getattr(request.state, "request_id", "")
|
|
66
|
+
|
|
67
|
+
# ── Log incoming request ──────────────────────────────
|
|
68
|
+
logger.info(
|
|
69
|
+
"Incoming request",
|
|
70
|
+
method=request.method,
|
|
71
|
+
path=request.url.path,
|
|
72
|
+
client_ip=request.client.host if request.client else "unknown",
|
|
73
|
+
user_agent=request.headers.get("user-agent", ""),
|
|
74
|
+
request_id=request_id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
response: Response = await call_next(request)
|
|
78
|
+
|
|
79
|
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
80
|
+
|
|
81
|
+
# ── Log outgoing response ─────────────────────────────
|
|
82
|
+
log_fn = logger.error if response.status_code >= 400 else logger.info
|
|
83
|
+
log_fn(
|
|
84
|
+
"Request completed",
|
|
85
|
+
method=request.method,
|
|
86
|
+
path=request.url.path,
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
duration_ms=elapsed_ms,
|
|
89
|
+
request_id=request_id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
response.headers["X-Process-Time-Ms"] = str(elapsed_ms)
|
|
93
|
+
return response
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Security headers middleware ──────────────────────────────────
|
|
97
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
98
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
99
|
+
response = await call_next(request)
|
|
100
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
101
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
102
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
103
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
104
|
+
if settings.is_production:
|
|
105
|
+
response.headers["Strict-Transport-Security"] = (
|
|
106
|
+
"max-age=31536000; includeSubDomains"
|
|
107
|
+
)
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AuditMiddleware(BaseHTTPMiddleware):
|
|
112
|
+
"""Persist an audit entry for every successful state-changing API request."""
|
|
113
|
+
|
|
114
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
115
|
+
response = await call_next(request)
|
|
116
|
+
if (
|
|
117
|
+
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
|
|
118
|
+
or not request.url.path.startswith("/api/")
|
|
119
|
+
or response.status_code >= 400
|
|
120
|
+
):
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
user_id = None
|
|
124
|
+
authorization = request.headers.get("authorization", "")
|
|
125
|
+
if authorization.lower().startswith("bearer "):
|
|
126
|
+
try:
|
|
127
|
+
from app.core.security import decode_token
|
|
128
|
+
|
|
129
|
+
user_id = decode_token(
|
|
130
|
+
authorization.split(" ", 1)[1], expected_type="access"
|
|
131
|
+
).get("sub")
|
|
132
|
+
except Exception:
|
|
133
|
+
user_id = None
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
from app.db.session import AsyncSessionLocal
|
|
137
|
+
from app.services.audit import AuditService
|
|
138
|
+
|
|
139
|
+
async with AsyncSessionLocal() as session:
|
|
140
|
+
await AuditService(session).log(
|
|
141
|
+
action=request.method.lower(),
|
|
142
|
+
resource=request.url.path,
|
|
143
|
+
user_id=user_id,
|
|
144
|
+
metadata={"status_code": response.status_code},
|
|
145
|
+
ip_address=request.client.host if request.client else None,
|
|
146
|
+
user_agent=request.headers.get("user-agent"),
|
|
147
|
+
request_id=getattr(request.state, "request_id", None),
|
|
148
|
+
)
|
|
149
|
+
await session.commit()
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.error("Request audit logging failed", error=str(exc))
|
|
152
|
+
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Registration helper ──────────────────────────────────────────
|
|
157
|
+
def register_middleware(app: FastAPI) -> None:
|
|
158
|
+
# Order matters: outermost middleware runs first on request, last on response.
|
|
159
|
+
|
|
160
|
+
# CORS (must be early so preflight OPTIONS pass)
|
|
161
|
+
app.add_middleware(
|
|
162
|
+
CORSMiddleware,
|
|
163
|
+
allow_origins=settings.ALLOWED_ORIGINS,
|
|
164
|
+
allow_credentials=True,
|
|
165
|
+
allow_methods=["*"],
|
|
166
|
+
allow_headers=["*"],
|
|
167
|
+
expose_headers=["X-Request-ID", "X-Process-Time-Ms"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Trusted hosts
|
|
171
|
+
if settings.is_production:
|
|
172
|
+
app.add_middleware(
|
|
173
|
+
TrustedHostMiddleware,
|
|
174
|
+
allowed_hosts=settings.ALLOWED_HOSTS,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Security headers
|
|
178
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
179
|
+
|
|
180
|
+
# Record successful state-changing API requests.
|
|
181
|
+
app.add_middleware(AuditMiddleware)
|
|
182
|
+
|
|
183
|
+
# Starlette runs the last added middleware first on incoming requests.
|
|
184
|
+
# Logging is added before Request-ID so it runs after the ID is set.
|
|
185
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
186
|
+
|
|
187
|
+
# Request-ID runs before logging so the logger can read the ID.
|
|
188
|
+
app.add_middleware(RequestIDMiddleware)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from app.core.logging import get_request_id
|
|
5
|
+
from app.utils.casing import CamelModel, keys_to_camel
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── Pagination metadata ──────────────────────────────────────────
|
|
11
|
+
class PaginationMeta(CamelModel):
|
|
12
|
+
page: int
|
|
13
|
+
page_size: int
|
|
14
|
+
total_items: int
|
|
15
|
+
total_pages: int
|
|
16
|
+
has_next: bool
|
|
17
|
+
has_prev: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Standard envelope models ─────────────────────────────────────
|
|
21
|
+
class APIResponse(CamelModel, Generic[T]):
|
|
22
|
+
success: bool
|
|
23
|
+
message: str
|
|
24
|
+
data: Optional[T] = None
|
|
25
|
+
meta: Optional[Dict[str, Any]] = None
|
|
26
|
+
error_code: Optional[str] = None
|
|
27
|
+
details: Any = None
|
|
28
|
+
timestamp: str = ""
|
|
29
|
+
request_id: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PaginatedResponse(CamelModel, Generic[T]):
|
|
33
|
+
success: bool
|
|
34
|
+
message: str
|
|
35
|
+
data: List[T]
|
|
36
|
+
pagination: PaginationMeta
|
|
37
|
+
meta: Optional[Dict[str, Any]] = None
|
|
38
|
+
timestamp: str = ""
|
|
39
|
+
request_id: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Builder helpers ──────────────────────────────────────────────
|
|
43
|
+
def _now() -> str:
|
|
44
|
+
return datetime.now(timezone.utc).isoformat()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _request_id_or_current(request_id: Optional[str]) -> str:
|
|
48
|
+
return request_id or get_request_id()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def success_response(
|
|
52
|
+
data: Any = None,
|
|
53
|
+
message: str = "Success",
|
|
54
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
55
|
+
request_id: Optional[str] = None,
|
|
56
|
+
) -> APIResponse:
|
|
57
|
+
return APIResponse(
|
|
58
|
+
success=True,
|
|
59
|
+
message=message,
|
|
60
|
+
data=keys_to_camel(data),
|
|
61
|
+
meta=keys_to_camel(meta),
|
|
62
|
+
timestamp=_now(),
|
|
63
|
+
request_id=_request_id_or_current(request_id),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def error_response(
|
|
68
|
+
message: str = "An error occurred",
|
|
69
|
+
error_code: str = "ERROR",
|
|
70
|
+
details: Any = None,
|
|
71
|
+
request_id: Optional[str] = None,
|
|
72
|
+
) -> APIResponse:
|
|
73
|
+
return APIResponse(
|
|
74
|
+
success=False,
|
|
75
|
+
message=message,
|
|
76
|
+
error_code=error_code,
|
|
77
|
+
details=keys_to_camel(details),
|
|
78
|
+
timestamp=_now(),
|
|
79
|
+
request_id=_request_id_or_current(request_id),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def paginated_response(
|
|
84
|
+
data: List[Any],
|
|
85
|
+
total_items: int,
|
|
86
|
+
page: int,
|
|
87
|
+
page_size: int,
|
|
88
|
+
message: str = "Success",
|
|
89
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
90
|
+
request_id: Optional[str] = None,
|
|
91
|
+
) -> PaginatedResponse:
|
|
92
|
+
total_pages = (total_items + page_size - 1) // page_size if page_size > 0 else 0
|
|
93
|
+
return PaginatedResponse(
|
|
94
|
+
success=True,
|
|
95
|
+
message=message,
|
|
96
|
+
data=keys_to_camel(data),
|
|
97
|
+
pagination=PaginationMeta(
|
|
98
|
+
page=page,
|
|
99
|
+
page_size=page_size,
|
|
100
|
+
total_items=total_items,
|
|
101
|
+
total_pages=total_pages,
|
|
102
|
+
has_next=page < total_pages,
|
|
103
|
+
has_prev=page > 1,
|
|
104
|
+
),
|
|
105
|
+
meta=keys_to_camel(meta),
|
|
106
|
+
timestamp=_now(),
|
|
107
|
+
request_id=_request_id_or_current(request_id),
|
|
108
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import secrets
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from jose import JWTError, jwt
|
|
9
|
+
from passlib.context import CryptContext
|
|
10
|
+
|
|
11
|
+
from app.core.config import settings
|
|
12
|
+
from app.core.exceptions import UnauthorizedException
|
|
13
|
+
|
|
14
|
+
# ── Password hashing (bcrypt, cost=12) ──────────────────────────
|
|
15
|
+
pwd_context = CryptContext(
|
|
16
|
+
schemes=["bcrypt"],
|
|
17
|
+
deprecated="auto",
|
|
18
|
+
bcrypt__rounds=12, # work factor — increase for more security
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_password(plain: str) -> str:
|
|
23
|
+
"""Hash a plain-text password using bcrypt."""
|
|
24
|
+
return pwd_context.hash(plain)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
28
|
+
"""Verify a plain-text password against its bcrypt hash."""
|
|
29
|
+
return pwd_context.verify(plain, hashed)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── JWT helpers ──────────────────────────────────────────────────
|
|
33
|
+
def _utcnow() -> datetime:
|
|
34
|
+
return datetime.now(timezone.utc)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_access_token(
|
|
38
|
+
subject: Any,
|
|
39
|
+
extra_claims: Optional[Dict] = None,
|
|
40
|
+
) -> Tuple[str, datetime]:
|
|
41
|
+
expire = _utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
|
42
|
+
payload = {
|
|
43
|
+
"sub": str(subject),
|
|
44
|
+
"exp": expire,
|
|
45
|
+
"iat": _utcnow(),
|
|
46
|
+
"jti": str(uuid.uuid4()),
|
|
47
|
+
"type": "access",
|
|
48
|
+
**(extra_claims or {}),
|
|
49
|
+
}
|
|
50
|
+
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
51
|
+
return token, expire
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_refresh_token(subject: Any) -> Tuple[str, datetime]:
|
|
55
|
+
expire = _utcnow() + timedelta(days=settings.refresh_token_expire_days)
|
|
56
|
+
payload = {
|
|
57
|
+
"sub": str(subject),
|
|
58
|
+
"exp": expire,
|
|
59
|
+
"iat": _utcnow(),
|
|
60
|
+
"jti": str(uuid.uuid4()),
|
|
61
|
+
"type": "refresh",
|
|
62
|
+
}
|
|
63
|
+
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
64
|
+
return token, expire
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_password_reset_token(email: str) -> str:
|
|
68
|
+
expire = _utcnow() + timedelta(minutes=settings.password_reset_token_expire_minutes)
|
|
69
|
+
payload = {"sub": email, "exp": expire, "type": "password_reset"}
|
|
70
|
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_token(token: str, expected_type: str = "access") -> Dict:
|
|
74
|
+
"""
|
|
75
|
+
Decode and validate a JWT. Raises UnauthorizedException on failure.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
payload = jwt.decode(
|
|
79
|
+
token, settings.SECRET_KEY, algorithms=[settings.algorithm]
|
|
80
|
+
)
|
|
81
|
+
except JWTError as exc:
|
|
82
|
+
raise UnauthorizedException(f"Invalid or expired token: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
if payload.get("type") != expected_type:
|
|
85
|
+
raise UnauthorizedException(f"Expected token type '{expected_type}'")
|
|
86
|
+
|
|
87
|
+
return payload
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Webhook signature verification ──────────────────────────────
|
|
91
|
+
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Verify an HMAC-SHA256 webhook signature.
|
|
94
|
+
Expected header format: 'sha256=<hex_digest>'
|
|
95
|
+
"""
|
|
96
|
+
expected = (
|
|
97
|
+
"sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
98
|
+
)
|
|
99
|
+
return hmac.compare_digest(expected, signature)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Secure random helpers ────────────────────────────────────────
|
|
103
|
+
def generate_secure_token(nbytes: int = 32) -> str:
|
|
104
|
+
"""Generate a URL-safe random token (for email verification, API keys, etc.)."""
|
|
105
|
+
return secrets.token_urlsafe(nbytes)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def hash_opaque_token(token: str) -> str:
|
|
109
|
+
"""Return a one-way digest suitable for storing an opaque token."""
|
|
110
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_api_key() -> str:
|
|
114
|
+
"""Generate a prefixed API key."""
|
|
115
|
+
return f"sk_{secrets.token_urlsafe(40)}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database package."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from app.db.models.user import User
|
|
2
|
+
from app.db.models.audit_log import AuditLog
|
|
3
|
+
from app.db.models.auth_token import (
|
|
4
|
+
EmailVerificationToken,
|
|
5
|
+
PasswordResetToken,
|
|
6
|
+
UserSession,
|
|
7
|
+
)
|
|
8
|
+
from app.db.models.notification import Notification
|
|
9
|
+
from app.db.models.revoked_token import RevokedToken
|
|
10
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
|
|
5
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.names import APP_SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditLog(Base):
|
|
13
|
+
__tablename__ = "audit_logs"
|
|
14
|
+
|
|
15
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
16
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
17
|
+
)
|
|
18
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
19
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ── Who ───────────────────────────────────────────────────
|
|
23
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
24
|
+
UUID(as_uuid=True),
|
|
25
|
+
ForeignKey("auth.users.id", ondelete="SET NULL"),
|
|
26
|
+
nullable=True,
|
|
27
|
+
index=True,
|
|
28
|
+
)
|
|
29
|
+
user_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
30
|
+
|
|
31
|
+
# ── What ──────────────────────────────────────────────────
|
|
32
|
+
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
|
33
|
+
resource: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
|
34
|
+
resource_id: Mapped[str | None] = mapped_column(
|
|
35
|
+
String(255), nullable=True, index=True
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ── Payload ───────────────────────────────────────────────
|
|
39
|
+
old_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
40
|
+
new_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
41
|
+
log_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
42
|
+
|
|
43
|
+
# ── Context ───────────────────────────────────────────────
|
|
44
|
+
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
|
45
|
+
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
46
|
+
request_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
47
|
+
|
|
48
|
+
# ── Relationship ──────────────────────────────────────────
|
|
49
|
+
user: Mapped["User"] = relationship("User", back_populates="audit_logs")
|
|
50
|
+
|
|
51
|
+
__table_args__ = (
|
|
52
|
+
Index("ix_audit_resource_action", "resource", "action"),
|
|
53
|
+
Index("ix_audit_user_resource", "user_id", "resource"),
|
|
54
|
+
{"schema": APP_SCHEMA},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"<AuditLog action={self.action} resource={self.resource} id={self.resource_id}>"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
|
|
5
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmailVerificationToken(Base):
|
|
13
|
+
__tablename__ = "email_verification_tokens"
|
|
14
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
15
|
+
|
|
16
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
17
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
18
|
+
)
|
|
19
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
20
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
21
|
+
)
|
|
22
|
+
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
23
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
24
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
25
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
26
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PasswordResetToken(Base):
|
|
31
|
+
__tablename__ = "password_reset_tokens"
|
|
32
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
33
|
+
|
|
34
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
35
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
36
|
+
)
|
|
37
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
38
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
39
|
+
)
|
|
40
|
+
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
41
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
42
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
43
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
44
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserSession(Base):
|
|
49
|
+
__tablename__ = "user_sessions"
|
|
50
|
+
__table_args__ = (
|
|
51
|
+
Index("ix_user_sessions_user_active", "user_id", "revoked_at"),
|
|
52
|
+
{"schema": AUTH_SCHEMA},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
56
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
57
|
+
)
|
|
58
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
59
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
60
|
+
)
|
|
61
|
+
refresh_jti: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
62
|
+
previous_refresh_jti: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
63
|
+
user_agent: Mapped[str | None] = mapped_column(Text)
|
|
64
|
+
ip_address: Mapped[str | None] = mapped_column(String(45))
|
|
65
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
66
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
67
|
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
68
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
69
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
user = relationship("User", back_populates="sessions")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
|
5
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.common import BaseModelMixin
|
|
10
|
+
from app.db.schemas.names import APP_SCHEMA
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Notification(BaseModelMixin, Base):
|
|
14
|
+
__tablename__ = "notifications"
|
|
15
|
+
__table_args__ = {"schema": APP_SCHEMA}
|
|
16
|
+
|
|
17
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
18
|
+
UUID(as_uuid=True),
|
|
19
|
+
ForeignKey("auth.users.id", ondelete="CASCADE"),
|
|
20
|
+
nullable=False,
|
|
21
|
+
index=True,
|
|
22
|
+
)
|
|
23
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
24
|
+
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
25
|
+
notification_type: Mapped[str] = mapped_column(
|
|
26
|
+
String(100), nullable=False, index=True
|
|
27
|
+
) # e.g. "info", "warning", "success", "error", "system"
|
|
28
|
+
|
|
29
|
+
# Channel: "in_app" | "email" | "push" | "webhook"
|
|
30
|
+
channel: Mapped[str] = mapped_column(String(50), default="in_app", nullable=False)
|
|
31
|
+
|
|
32
|
+
is_read: Mapped[bool] = mapped_column(
|
|
33
|
+
Boolean, default=False, nullable=False, index=True
|
|
34
|
+
)
|
|
35
|
+
read_at: Mapped[datetime | None] = mapped_column(
|
|
36
|
+
DateTime(timezone=True), nullable=True
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Arbitrary extra payload (deep link, action URL, entity ref, etc.)
|
|
40
|
+
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
41
|
+
|
|
42
|
+
user: Mapped["User"] = relationship("User", back_populates="notifications")
|
|
43
|
+
|
|
44
|
+
def mark_read(self) -> None:
|
|
45
|
+
self.is_read = True
|
|
46
|
+
self.read_at = datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"<Notification user={self.user_id} type={self.notification_type} read={self.is_read}>"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DateTime, String
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
from app.db.base import Base
|
|
7
|
+
from app.db.schemas.common import BaseModelMixin
|
|
8
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RevokedToken(BaseModelMixin, Base):
|
|
12
|
+
__tablename__ = "revoked_tokens"
|
|
13
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
14
|
+
|
|
15
|
+
jti: Mapped[str] = mapped_column(
|
|
16
|
+
String(64), unique=True, nullable=False, index=True
|
|
17
|
+
)
|
|
18
|
+
token_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
19
|
+
expires_at: Mapped[datetime] = mapped_column(
|
|
20
|
+
DateTime(timezone=True), nullable=False
|
|
21
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Boolean, DateTime, String
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
5
|
+
|
|
6
|
+
from app.db.base import Base
|
|
7
|
+
from app.db.schemas.common import BaseModelMixin
|
|
8
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(BaseModelMixin, Base):
|
|
12
|
+
__tablename__ = "users"
|
|
13
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
14
|
+
|
|
15
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
16
|
+
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
|
17
|
+
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
18
|
+
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
19
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
20
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
21
|
+
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
22
|
+
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
23
|
+
|
|
24
|
+
notifications: Mapped[list["Notification"]] = relationship(
|
|
25
|
+
"Notification", back_populates="user", lazy="dynamic"
|
|
26
|
+
)
|
|
27
|
+
audit_logs: Mapped[list["AuditLog"]] = relationship(
|
|
28
|
+
"AuditLog", back_populates="user", lazy="dynamic"
|
|
29
|
+
)
|
|
30
|
+
sessions: Mapped[list["UserSession"]] = relationship(
|
|
31
|
+
"UserSession", back_populates="user", lazy="dynamic"
|
|
32
|
+
)
|
|
33
|
+
|