simple-module-users 0.0.1__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.
- simple_module_users-0.0.1.dist-info/METADATA +88 -0
- simple_module_users-0.0.1.dist-info/RECORD +56 -0
- simple_module_users-0.0.1.dist-info/WHEEL +4 -0
- simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
- users/__init__.py +0 -0
- users/backend.py +85 -0
- users/bootstrap.py +246 -0
- users/cli.py +75 -0
- users/components/IndexFilters.tsx +72 -0
- users/components/RolesTab.tsx +72 -0
- users/constants.py +42 -0
- users/contracts/__init__.py +0 -0
- users/contracts/events.py +32 -0
- users/contracts/schemas.py +85 -0
- users/db_adapter.py +48 -0
- users/deps.py +83 -0
- users/endpoints/__init__.py +1 -0
- users/endpoints/api.py +227 -0
- users/endpoints/api_admin.py +167 -0
- users/endpoints/views.py +220 -0
- users/exceptions.py +18 -0
- users/mailer/__init__.py +33 -0
- users/mailer/console.py +27 -0
- users/mailer/smtp.py +77 -0
- users/mailer/templates/.gitkeep +0 -0
- users/mailer/templates/invite.txt +1 -0
- users/mailer/templates/reset_password.txt +1 -0
- users/mailer/templates/verify_email.txt +1 -0
- users/manager.py +146 -0
- users/middleware.py +143 -0
- users/models/__init__.py +24 -0
- users/models/_base.py +9 -0
- users/models/access_token.py +33 -0
- users/models/role.py +34 -0
- users/models/user.py +67 -0
- users/models/user_role.py +39 -0
- users/module.py +155 -0
- users/package.json +16 -0
- users/pages/.gitkeep +0 -0
- users/pages/AcceptInvite.tsx +106 -0
- users/pages/ForgotPassword.tsx +90 -0
- users/pages/Login.tsx +181 -0
- users/pages/Profile.tsx +112 -0
- users/pages/Register.tsx +152 -0
- users/pages/ResetPassword.tsx +112 -0
- users/pages/Users/Edit.tsx +293 -0
- users/pages/Users/Index.tsx +296 -0
- users/pages/Users/Invite.tsx +135 -0
- users/pages/VerifyEmail.tsx +110 -0
- users/py.typed +0 -0
- users/rate_limit.py +59 -0
- users/roles_cache.py +58 -0
- users/service.py +257 -0
- users/settings.py +99 -0
- users/state.py +33 -0
users/manager.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""UserManager — handles lifecycle hooks + custom verification-token helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, Request
|
|
11
|
+
from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions
|
|
12
|
+
from fastapi_users.jwt import generate_jwt
|
|
13
|
+
|
|
14
|
+
from users.contracts.events import UserRegistered
|
|
15
|
+
from users.db_adapter import UserDatabaseWithRoles, get_user_db
|
|
16
|
+
from users.mailer import Mailer
|
|
17
|
+
from users.models import User
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from users.settings import UsersSettings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
24
|
+
"""Customizes password validation, token secrets, and lifecycle emails."""
|
|
25
|
+
|
|
26
|
+
# Secrets and lifetimes pulled from settings at construction time so
|
|
27
|
+
# subclasses of UserManager work in tests without full app startup.
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
user_db: UserDatabaseWithRoles,
|
|
31
|
+
mailer: Mailer,
|
|
32
|
+
settings: UsersSettings,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(user_db)
|
|
35
|
+
self.mailer = mailer
|
|
36
|
+
self.reset_password_token_secret = settings.reset_password_token_secret
|
|
37
|
+
self.verification_token_secret = settings.verification_token_secret
|
|
38
|
+
self.reset_password_token_lifetime_seconds = settings.reset_password_token_lifetime_seconds
|
|
39
|
+
self.verification_token_lifetime_seconds = settings.verification_token_lifetime_seconds
|
|
40
|
+
|
|
41
|
+
# ── Password policy ──────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
async def validate_password(self, password: str, user) -> None:
|
|
44
|
+
if len(password) < 8:
|
|
45
|
+
raise exceptions.InvalidPasswordException(
|
|
46
|
+
reason="Password must be at least 8 characters"
|
|
47
|
+
)
|
|
48
|
+
if password.lower() in user.email.lower():
|
|
49
|
+
raise exceptions.InvalidPasswordException(reason="Password cannot contain your email")
|
|
50
|
+
if password.isdigit():
|
|
51
|
+
raise exceptions.InvalidPasswordException(reason="Password cannot be all numbers")
|
|
52
|
+
|
|
53
|
+
# ── Lifecycle hooks ──────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async def on_after_register(self, user: User, request: Request | None = None) -> None:
|
|
56
|
+
await self._publish_user_registered(user, request)
|
|
57
|
+
if not user.is_verified:
|
|
58
|
+
# Kicks off on_after_request_verify, which sends the email
|
|
59
|
+
await self.request_verify(user, request)
|
|
60
|
+
|
|
61
|
+
async def _publish_user_registered(self, user: User, request: Request | None) -> None:
|
|
62
|
+
"""Emit ``UserRegistered`` on the app-wide event bus.
|
|
63
|
+
|
|
64
|
+
CLI bootstrap and unit tests instantiate the manager without a
|
|
65
|
+
request, so there's no app context from which to reach the bus —
|
|
66
|
+
publication is best-effort in those cases.
|
|
67
|
+
"""
|
|
68
|
+
if request is None:
|
|
69
|
+
return
|
|
70
|
+
await request.app.state.sm.event_bus.publish(
|
|
71
|
+
UserRegistered(user_id=user.id, email=user.email)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def on_after_forgot_password(
|
|
75
|
+
self, user: User, token: str, request: Request | None = None
|
|
76
|
+
) -> None:
|
|
77
|
+
await self.mailer.send_password_reset(user.email, token)
|
|
78
|
+
|
|
79
|
+
async def on_after_request_verify(
|
|
80
|
+
self, user: User, token: str, request: Request | None = None
|
|
81
|
+
) -> None:
|
|
82
|
+
await self.mailer.send_verification(user.email, token)
|
|
83
|
+
|
|
84
|
+
async def on_after_login(
|
|
85
|
+
self, user: User, request: Request | None = None, response=None
|
|
86
|
+
) -> None:
|
|
87
|
+
user.last_login_at = datetime.now(UTC)
|
|
88
|
+
await self.user_db.update(user, {"last_login_at": user.last_login_at})
|
|
89
|
+
|
|
90
|
+
# ── Token helpers (no email side-effect) ─────────────────
|
|
91
|
+
|
|
92
|
+
async def generate_verification_token(self, user: User) -> str:
|
|
93
|
+
"""Mint a verify-audience JWT without firing on_after_request_verify.
|
|
94
|
+
|
|
95
|
+
Used by the admin-invite flow: the verify-token primitive is reused
|
|
96
|
+
for invites, but the email template differs. request_verify() couples
|
|
97
|
+
token generation with email send — this decouples them.
|
|
98
|
+
"""
|
|
99
|
+
token_data = {
|
|
100
|
+
"sub": str(user.id),
|
|
101
|
+
"email": user.email,
|
|
102
|
+
"aud": self.verification_token_audience,
|
|
103
|
+
}
|
|
104
|
+
return generate_jwt(
|
|
105
|
+
token_data,
|
|
106
|
+
self.verification_token_secret,
|
|
107
|
+
self.verification_token_lifetime_seconds,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def generate_reset_password_token(self, user: User) -> str:
|
|
111
|
+
"""Mint a reset-audience JWT without firing on_after_forgot_password.
|
|
112
|
+
|
|
113
|
+
Shape matches ``BaseUserManager.forgot_password``: the payload includes
|
|
114
|
+
``password_fgpt`` (a bcrypt of the current hashed_password) so the token
|
|
115
|
+
invalidates when the password changes. Used by the admin
|
|
116
|
+
reset-password-link endpoint, where the admin copies the link instead
|
|
117
|
+
of triggering an email.
|
|
118
|
+
|
|
119
|
+
The fingerprint must be bcrypt-style because the stock
|
|
120
|
+
``reset_password`` path in fastapi-users verifies it with
|
|
121
|
+
``password_helper.verify_and_update``. Bcrypt is CPU-bound (~100ms at
|
|
122
|
+
default rounds) so we offload it to a worker thread — otherwise a
|
|
123
|
+
single admin action would stall the event loop for other requests.
|
|
124
|
+
"""
|
|
125
|
+
fingerprint = await asyncio.to_thread(self.password_helper.hash, user.hashed_password)
|
|
126
|
+
token_data = {
|
|
127
|
+
"sub": str(user.id),
|
|
128
|
+
"password_fgpt": fingerprint,
|
|
129
|
+
"aud": self.reset_password_token_audience,
|
|
130
|
+
}
|
|
131
|
+
return generate_jwt(
|
|
132
|
+
token_data,
|
|
133
|
+
self.reset_password_token_secret,
|
|
134
|
+
self.reset_password_token_lifetime_seconds,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def get_user_manager(
|
|
139
|
+
request: Request,
|
|
140
|
+
user_db: UserDatabaseWithRoles = Depends(get_user_db),
|
|
141
|
+
):
|
|
142
|
+
"""FastAPI dependency — pulls mailer and settings off app.state."""
|
|
143
|
+
users = request.app.state.users
|
|
144
|
+
mailer = users.mailer
|
|
145
|
+
settings = users.settings
|
|
146
|
+
yield UserManager(user_db, mailer, settings)
|
users/middleware.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Local-user auth middleware — replaces the Keycloak session reader.
|
|
2
|
+
|
|
3
|
+
Reads ``session["user_id"]``, loads the User row with eagerly-loaded roles,
|
|
4
|
+
builds a UserContext, and sets ``request.state.user`` + the
|
|
5
|
+
``current_user_id`` ContextVar consumed by DB audit listeners.
|
|
6
|
+
|
|
7
|
+
The resolved ``UserContext`` is cached in the signed session cookie under
|
|
8
|
+
``session["user_ctx"]`` so subsequent requests skip the DB lookup. The cache
|
|
9
|
+
is refreshed when the session is cleared (logout / rotation) or when the
|
|
10
|
+
cached payload is missing/invalid. Trade-off: admin-side changes (role
|
|
11
|
+
assignment, disable/enable) do not take effect until the affected user's
|
|
12
|
+
session is recreated (re-login or session expiry); acceptable for this app.
|
|
13
|
+
|
|
14
|
+
Registered via ``UsersModule.register_middleware``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
from auth.contracts.schemas import UserContext
|
|
23
|
+
from simple_module_db.listeners import current_user_id
|
|
24
|
+
from sqlalchemy import select
|
|
25
|
+
from sqlalchemy.orm import selectinload
|
|
26
|
+
from starlette.requests import Request
|
|
27
|
+
from starlette.responses import RedirectResponse
|
|
28
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
29
|
+
|
|
30
|
+
from users.constants import SESSION_USER_ID_KEY
|
|
31
|
+
from users.models import User
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
SESSION_USER_CTX_KEY = "user_ctx"
|
|
36
|
+
_SESSION_USER_ID_KEY = SESSION_USER_ID_KEY
|
|
37
|
+
_SESSION_NEXT_KEY = "next"
|
|
38
|
+
_SCOPE_HTTP = "http"
|
|
39
|
+
_LOGIN_REDIRECT = "/users/login"
|
|
40
|
+
|
|
41
|
+
# Paths that don't require authentication.
|
|
42
|
+
PUBLIC_PATHS = (
|
|
43
|
+
"/users/login",
|
|
44
|
+
"/users/register",
|
|
45
|
+
"/users/forgot-password",
|
|
46
|
+
"/users/reset-password",
|
|
47
|
+
"/users/verify",
|
|
48
|
+
"/users/invite/accept",
|
|
49
|
+
"/api/users/auth/",
|
|
50
|
+
"/api/users/register",
|
|
51
|
+
"/health",
|
|
52
|
+
"/static/",
|
|
53
|
+
"/api/docs",
|
|
54
|
+
"/api/redoc",
|
|
55
|
+
"/openapi.json",
|
|
56
|
+
"/i18n/",
|
|
57
|
+
)
|
|
58
|
+
EXACT_PUBLIC_PATHS = ("/",)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthMiddleware:
|
|
62
|
+
"""Redirect unauthenticated users to /users/login.
|
|
63
|
+
|
|
64
|
+
On cache hit (``session["user_ctx"]`` present), skips the DB entirely.
|
|
65
|
+
On cache miss, loads the user with roles, validates active/enabled, and
|
|
66
|
+
writes the resolved context back to the session.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
70
|
+
self.app = app
|
|
71
|
+
|
|
72
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
73
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
74
|
+
await self.app(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
path = scope["path"]
|
|
78
|
+
is_public = any(path.startswith(p) for p in PUBLIC_PATHS) or path in EXACT_PUBLIC_PATHS
|
|
79
|
+
|
|
80
|
+
session = scope["session"]
|
|
81
|
+
raw_user_id = session.get(_SESSION_USER_ID_KEY)
|
|
82
|
+
|
|
83
|
+
user_ctx: UserContext | None = None
|
|
84
|
+
if raw_user_id:
|
|
85
|
+
user_id_str = str(raw_user_id)
|
|
86
|
+
# Fast path — rebuild from the signed session cookie.
|
|
87
|
+
user_ctx = UserContext.from_session_dict(session.get(SESSION_USER_CTX_KEY))
|
|
88
|
+
if user_ctx is None or user_ctx.id != user_id_str:
|
|
89
|
+
try:
|
|
90
|
+
user_uuid = uuid.UUID(user_id_str)
|
|
91
|
+
except (ValueError, TypeError):
|
|
92
|
+
logger.warning("Invalid user_id in session: %r", raw_user_id)
|
|
93
|
+
session.pop(_SESSION_USER_ID_KEY, None)
|
|
94
|
+
session.pop(SESSION_USER_CTX_KEY, None)
|
|
95
|
+
user_ctx = None
|
|
96
|
+
else:
|
|
97
|
+
user_ctx = await self._load_user(scope, user_uuid)
|
|
98
|
+
if user_ctx is None:
|
|
99
|
+
# User was deleted / disabled since session creation.
|
|
100
|
+
session.pop(_SESSION_USER_ID_KEY, None)
|
|
101
|
+
session.pop(SESSION_USER_CTX_KEY, None)
|
|
102
|
+
else:
|
|
103
|
+
session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict()
|
|
104
|
+
|
|
105
|
+
if user_ctx is None and not is_public:
|
|
106
|
+
request = Request(scope)
|
|
107
|
+
session[_SESSION_NEXT_KEY] = str(request.url)
|
|
108
|
+
response = RedirectResponse(_LOGIN_REDIRECT, status_code=302)
|
|
109
|
+
await response(scope, receive, send)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if user_ctx is not None:
|
|
113
|
+
request = Request(scope)
|
|
114
|
+
request.state.user = user_ctx
|
|
115
|
+
token = current_user_id.set(user_ctx.id)
|
|
116
|
+
try:
|
|
117
|
+
await self.app(scope, receive, send)
|
|
118
|
+
finally:
|
|
119
|
+
current_user_id.reset(token)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
await self.app(scope, receive, send)
|
|
123
|
+
|
|
124
|
+
async def _load_user(self, scope: Scope, user_id: uuid.UUID) -> UserContext | None:
|
|
125
|
+
"""Open a fresh session from app.state.db and load the User + roles.
|
|
126
|
+
|
|
127
|
+
Returns a UserContext, or None if the user doesn't exist or is
|
|
128
|
+
disabled/inactive. The session is closed on exit; we never commit
|
|
129
|
+
(read-only).
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
session_factory = scope["app"].state.sm.db.session_factory
|
|
133
|
+
async with session_factory() as db_session:
|
|
134
|
+
stmt = select(User).where(User.id == user_id).options(selectinload(User.roles))
|
|
135
|
+
user = (await db_session.execute(stmt)).scalar_one_or_none()
|
|
136
|
+
if user is None:
|
|
137
|
+
return None
|
|
138
|
+
if not user.is_active or user.disabled_at is not None:
|
|
139
|
+
return None
|
|
140
|
+
return UserContext.from_user(user)
|
|
141
|
+
except Exception:
|
|
142
|
+
logger.exception("Failed to load user %s from DB; treating as unauthenticated", user_id)
|
|
143
|
+
return None
|
users/models/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""SQLModel tables for the users module — one entity per file under this package.
|
|
2
|
+
|
|
3
|
+
Existing imports like ``from users.models import User`` keep working via the
|
|
4
|
+
re-exports below.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
|
|
8
|
+
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
|
|
9
|
+
|
|
10
|
+
from users.models._base import Base
|
|
11
|
+
from users.models.access_token import UserAccessToken
|
|
12
|
+
from users.models.role import Role
|
|
13
|
+
from users.models.user import User
|
|
14
|
+
from users.models.user_role import UserRole
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Base",
|
|
18
|
+
"Role",
|
|
19
|
+
"SQLAlchemyAccessTokenDatabase",
|
|
20
|
+
"SQLAlchemyUserDatabase",
|
|
21
|
+
"User",
|
|
22
|
+
"UserAccessToken",
|
|
23
|
+
"UserRole",
|
|
24
|
+
]
|
users/models/_base.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared Base for the users module's tables.
|
|
2
|
+
|
|
3
|
+
Lives in its own module so individual entity files can import it without
|
|
4
|
+
triggering a package ``__init__`` cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from simple_module_db.base import create_module_base
|
|
8
|
+
|
|
9
|
+
Base = create_module_base("users")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Access-token table backing fastapi-users' DatabaseStrategy.
|
|
2
|
+
|
|
3
|
+
Column surface mirrors fastapi-users' ``SQLAlchemyBaseAccessTokenTableUUID``
|
|
4
|
+
so the ``SQLAlchemyAccessTokenDatabase`` adapter binds to it without
|
|
5
|
+
inheriting from the upstream base class.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from fastapi_users_db_sqlalchemy.generics import GUID, TIMESTAMPAware, now_utc
|
|
12
|
+
from sqlmodel import Field
|
|
13
|
+
|
|
14
|
+
from users.models._base import Base
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UserAccessToken(Base, table=True): # ty: ignore[unsupported-base]
|
|
18
|
+
"""fastapi-users DatabaseStrategy-backed access tokens."""
|
|
19
|
+
|
|
20
|
+
__tablename__ = "users_access_token"
|
|
21
|
+
|
|
22
|
+
token: str = Field(max_length=43, primary_key=True)
|
|
23
|
+
created_at: datetime = Field(
|
|
24
|
+
default_factory=now_utc,
|
|
25
|
+
sa_type=TIMESTAMPAware(timezone=True),
|
|
26
|
+
index=True,
|
|
27
|
+
)
|
|
28
|
+
user_id: uuid.UUID = Field(
|
|
29
|
+
sa_type=GUID,
|
|
30
|
+
foreign_key="users_user.id",
|
|
31
|
+
ondelete="CASCADE",
|
|
32
|
+
index=True, # PostgreSQL does not auto-index FKs
|
|
33
|
+
)
|
users/models/role.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Role table — a named role that holds permission strings."""
|
|
2
|
+
|
|
3
|
+
# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
|
|
4
|
+
# Relationship resolution requires runtime annotations.
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
9
|
+
from simple_module_db.mixins import AuditMixin
|
|
10
|
+
from sqlmodel import Field, Relationship
|
|
11
|
+
|
|
12
|
+
from users.models._base import Base
|
|
13
|
+
from users.models.user import User
|
|
14
|
+
from users.models.user_role import UserRole
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Role(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
18
|
+
"""A named role that can hold a set of permission strings."""
|
|
19
|
+
|
|
20
|
+
__tablename__ = "users_role"
|
|
21
|
+
|
|
22
|
+
id: uuid.UUID = Field(
|
|
23
|
+
default_factory=uuid.uuid4,
|
|
24
|
+
sa_type=GUID,
|
|
25
|
+
primary_key=True,
|
|
26
|
+
)
|
|
27
|
+
name: str = Field(max_length=64, unique=True, index=True)
|
|
28
|
+
description: str | None = Field(default=None, max_length=255)
|
|
29
|
+
|
|
30
|
+
users: list[User] = Relationship(
|
|
31
|
+
link_model=UserRole,
|
|
32
|
+
back_populates="roles",
|
|
33
|
+
sa_relationship_kwargs={"lazy": "noload"},
|
|
34
|
+
)
|
users/models/user.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Local user table.
|
|
2
|
+
|
|
3
|
+
Column surface mirrors fastapi-users' ``SQLAlchemyBaseUserTableUUID`` so the
|
|
4
|
+
``SQLAlchemyUserDatabase`` adapter binds to it without inheriting from the
|
|
5
|
+
upstream base class (which uses SQLAlchemy ``Mapped`` and is incompatible with
|
|
6
|
+
SQLModel's metaclass).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
|
|
10
|
+
# Relationship resolution requires runtime annotations (not stringified ones)
|
|
11
|
+
# for forward references like ``list["Role"]`` to work correctly.
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
18
|
+
from simple_module_db.mixins import AuditMixin
|
|
19
|
+
from sqlalchemy import DateTime, Index, text
|
|
20
|
+
from sqlmodel import Field, Relationship
|
|
21
|
+
|
|
22
|
+
from users.models._base import Base
|
|
23
|
+
from users.models.user_role import UserRole
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
# Resolved at runtime by SQLModel via the string forward ref;
|
|
27
|
+
# this import only feeds the type checker.
|
|
28
|
+
from users.models.role import Role
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
32
|
+
"""Local user. Column surface mirrors fastapi-users' SQLAlchemyBaseUserTableUUID."""
|
|
33
|
+
|
|
34
|
+
__tablename__ = "users_user"
|
|
35
|
+
|
|
36
|
+
id: uuid.UUID = Field(
|
|
37
|
+
default_factory=uuid.uuid4,
|
|
38
|
+
sa_type=GUID,
|
|
39
|
+
primary_key=True,
|
|
40
|
+
)
|
|
41
|
+
email: str = Field(max_length=320, unique=True, index=True)
|
|
42
|
+
hashed_password: str = Field(max_length=1024)
|
|
43
|
+
is_active: bool = Field(default=True)
|
|
44
|
+
is_superuser: bool = Field(default=False)
|
|
45
|
+
is_verified: bool = Field(default=False)
|
|
46
|
+
|
|
47
|
+
full_name: str | None = Field(default=None, max_length=255)
|
|
48
|
+
tenant_id: str | None = Field(default=None, max_length=50, index=True)
|
|
49
|
+
disabled_at: datetime | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
sa_type=DateTime(timezone=True),
|
|
52
|
+
)
|
|
53
|
+
last_login_at: datetime | None = Field(
|
|
54
|
+
default=None,
|
|
55
|
+
sa_type=DateTime(timezone=True),
|
|
56
|
+
index=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
roles: list["Role"] = Relationship(
|
|
60
|
+
link_model=UserRole,
|
|
61
|
+
back_populates="users",
|
|
62
|
+
sa_relationship_kwargs={"lazy": "noload"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Functional index so the ``lower(email)`` predicate used by
|
|
66
|
+
# ``UserDatabaseWithRoles.get_by_email`` can be served from an index.
|
|
67
|
+
__table_args__ = (Index("ix_users_user_email_lower", text("lower(email)")),)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Association table linking users and roles."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from fastapi_users_db_sqlalchemy.generics import GUID, now_utc
|
|
7
|
+
from sqlalchemy import DateTime, Index
|
|
8
|
+
from sqlmodel import Field
|
|
9
|
+
|
|
10
|
+
from users.models._base import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserRole(Base, table=True): # ty: ignore[unsupported-base]
|
|
14
|
+
"""Association table between users and roles."""
|
|
15
|
+
|
|
16
|
+
__tablename__ = "users_user_role"
|
|
17
|
+
|
|
18
|
+
user_id: uuid.UUID = Field(
|
|
19
|
+
sa_type=GUID,
|
|
20
|
+
foreign_key="users_user.id",
|
|
21
|
+
ondelete="CASCADE",
|
|
22
|
+
primary_key=True,
|
|
23
|
+
)
|
|
24
|
+
role_id: uuid.UUID = Field(
|
|
25
|
+
sa_type=GUID,
|
|
26
|
+
foreign_key="users_role.id",
|
|
27
|
+
ondelete="CASCADE",
|
|
28
|
+
primary_key=True,
|
|
29
|
+
)
|
|
30
|
+
assigned_at: datetime = Field(
|
|
31
|
+
default_factory=now_utc,
|
|
32
|
+
sa_type=DateTime(timezone=True),
|
|
33
|
+
)
|
|
34
|
+
assigned_by: str | None = Field(default=None, max_length=255)
|
|
35
|
+
|
|
36
|
+
# The composite PK covers ``user_id``-first lookups; add a standalone
|
|
37
|
+
# index on ``role_id`` for reverse lookups — PostgreSQL does not
|
|
38
|
+
# auto-index FKs.
|
|
39
|
+
__table_args__ = (Index("ix_users_user_role_role_id", "role_id"),)
|
users/module.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Users module — local-account authentication and user management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
9
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
10
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
11
|
+
|
|
12
|
+
from users.constants import (
|
|
13
|
+
ADMIN_ROLE_NAME,
|
|
14
|
+
PERM_USERS_MANAGE,
|
|
15
|
+
PERM_USERS_SELF_PROFILE,
|
|
16
|
+
USER_ROLE_NAME,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
|
|
22
|
+
_MODULE_DEPENDENCY_AUTH = "Auth"
|
|
23
|
+
|
|
24
|
+
# Menu URLs
|
|
25
|
+
_URL_USERS_ADMIN = "/users/admin"
|
|
26
|
+
_URL_USERS_ME = "/users/me"
|
|
27
|
+
_URL_USERS_LOGOUT = "/users/logout"
|
|
28
|
+
|
|
29
|
+
# Menu icons
|
|
30
|
+
_ICON_USERS = "users"
|
|
31
|
+
_ICON_USER = "user"
|
|
32
|
+
_ICON_LOG_OUT = "log-out"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UsersModule(ModuleBase):
|
|
36
|
+
meta = ModuleMeta(
|
|
37
|
+
name="Users",
|
|
38
|
+
route_prefix="/api/users",
|
|
39
|
+
view_prefix="/users",
|
|
40
|
+
depends_on=[_MODULE_DEPENDENCY_AUTH],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
44
|
+
import importlib
|
|
45
|
+
|
|
46
|
+
from auth.contracts.schemas import UserContext
|
|
47
|
+
|
|
48
|
+
from users.settings import UsersSettings
|
|
49
|
+
from users.state import UsersState
|
|
50
|
+
|
|
51
|
+
# SM009 is AST-based: a static `from settings.registration import ...`
|
|
52
|
+
# from a module helper is fine (plugin→plugin), but we resolve via
|
|
53
|
+
# importlib here to match the convention used framework-side and to
|
|
54
|
+
# keep the dependency direction one-way explicit.
|
|
55
|
+
register_module_settings = importlib.import_module(
|
|
56
|
+
"settings.registration"
|
|
57
|
+
).register_module_settings
|
|
58
|
+
|
|
59
|
+
register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s))
|
|
60
|
+
|
|
61
|
+
def serialize_principal(user: UserContext) -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"id": user.id,
|
|
64
|
+
"name": user.name,
|
|
65
|
+
"email": user.email,
|
|
66
|
+
"roles": user.roles,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
app.state.principal_serializer = serialize_principal
|
|
70
|
+
|
|
71
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
72
|
+
registry.add_group(
|
|
73
|
+
"Users",
|
|
74
|
+
[PERM_USERS_MANAGE, PERM_USERS_SELF_PROFILE],
|
|
75
|
+
)
|
|
76
|
+
registry.map_role(USER_ROLE_NAME, [PERM_USERS_SELF_PROFILE])
|
|
77
|
+
|
|
78
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
79
|
+
# Admin-only user management
|
|
80
|
+
registry.add(
|
|
81
|
+
MenuItem(
|
|
82
|
+
label="Users",
|
|
83
|
+
url=_URL_USERS_ADMIN,
|
|
84
|
+
icon=_ICON_USERS,
|
|
85
|
+
order=30,
|
|
86
|
+
section=MenuSection.SIDEBAR,
|
|
87
|
+
roles=[ADMIN_ROLE_NAME],
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
# Self-service: profile + logout live in the user dropdown.
|
|
91
|
+
registry.add(
|
|
92
|
+
MenuItem(
|
|
93
|
+
label="Profile",
|
|
94
|
+
url=_URL_USERS_ME,
|
|
95
|
+
icon=_ICON_USER,
|
|
96
|
+
order=990,
|
|
97
|
+
section=MenuSection.USER_DROPDOWN,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
registry.add(
|
|
101
|
+
MenuItem(
|
|
102
|
+
label="Logout",
|
|
103
|
+
url=_URL_USERS_LOGOUT,
|
|
104
|
+
icon=_ICON_LOG_OUT,
|
|
105
|
+
order=999,
|
|
106
|
+
section=MenuSection.USER_DROPDOWN,
|
|
107
|
+
method="post",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
112
|
+
from users.endpoints.api import register_auth_routes
|
|
113
|
+
from users.endpoints.views import router as views
|
|
114
|
+
|
|
115
|
+
# The register router is always mounted; its `allow_signup` gate lives
|
|
116
|
+
# on a per-request dependency, so toggling the setting at runtime takes
|
|
117
|
+
# effect without needing to remount.
|
|
118
|
+
register_auth_routes(api_router)
|
|
119
|
+
view_router.include_router(views)
|
|
120
|
+
|
|
121
|
+
def register_middleware(self, app: FastAPI) -> None:
|
|
122
|
+
from users.middleware import AuthMiddleware
|
|
123
|
+
|
|
124
|
+
app.add_middleware(AuthMiddleware)
|
|
125
|
+
|
|
126
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
127
|
+
"""Build the mailer, rate limiter, and apply production cookie params."""
|
|
128
|
+
import asyncio
|
|
129
|
+
|
|
130
|
+
from users.backend import reconfigure_cookie_transport
|
|
131
|
+
from users.bootstrap import bootstrap_admin_from_env
|
|
132
|
+
from users.deps import auth_backend
|
|
133
|
+
from users.mailer import build_mailer
|
|
134
|
+
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
|
|
135
|
+
from users.roles_cache import refresh_roles_cache
|
|
136
|
+
|
|
137
|
+
state = app.state.users
|
|
138
|
+
s = state.settings
|
|
139
|
+
state.mailer = build_mailer(s)
|
|
140
|
+
state.rate_limiter = LoginRateLimiter(
|
|
141
|
+
max_failures=s.login_rate_limit_failures,
|
|
142
|
+
window_seconds=s.login_rate_limit_window_seconds,
|
|
143
|
+
cooldown_seconds=s.login_rate_limit_cooldown_seconds,
|
|
144
|
+
)
|
|
145
|
+
state.auth_throughput_limiter = ThroughputLimiter(
|
|
146
|
+
max_attempts=s.auth_rate_limit_attempts,
|
|
147
|
+
window_seconds=s.auth_rate_limit_window_seconds,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
reconfigure_cookie_transport(auth_backend, s)
|
|
151
|
+
|
|
152
|
+
await asyncio.gather(
|
|
153
|
+
bootstrap_admin_from_env(app),
|
|
154
|
+
refresh_roles_cache(app),
|
|
155
|
+
)
|
users/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/users",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Users module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
users/pages/.gitkeep
ADDED
|
File without changes
|