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,291 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from app.api.v1.roles.repository import RoleRepository
|
|
6
|
+
from app.api.v1.users.repository import UserRepository
|
|
7
|
+
from app.api.v1.users.schema import UserCreate, UserResponse, UserUpdate
|
|
8
|
+
from app.core.exceptions import (
|
|
9
|
+
ConflictException,
|
|
10
|
+
ForbiddenException,
|
|
11
|
+
NotFoundException,
|
|
12
|
+
ValidationException,
|
|
13
|
+
)
|
|
14
|
+
from app.core.security import hash_password
|
|
15
|
+
from app.db.models import User
|
|
16
|
+
|
|
17
|
+
ROOT_ROLE_NAME = "SUPER ADMIN"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserService:
|
|
21
|
+
def __init__(self, session: AsyncSession):
|
|
22
|
+
self.repo = UserRepository(session)
|
|
23
|
+
self.roles = RoleRepository(session)
|
|
24
|
+
|
|
25
|
+
async def list_users(
|
|
26
|
+
self,
|
|
27
|
+
page: int,
|
|
28
|
+
page_size: int,
|
|
29
|
+
pagination: bool = True,
|
|
30
|
+
search: str | None = None,
|
|
31
|
+
sort_by: str | None = None,
|
|
32
|
+
sort_order: str | None = None,
|
|
33
|
+
) -> tuple[list[UserResponse], int]:
|
|
34
|
+
users, total = await self.repo.get_all_with_permissions(
|
|
35
|
+
page,
|
|
36
|
+
page_size,
|
|
37
|
+
pagination,
|
|
38
|
+
search=search,
|
|
39
|
+
sort_by=sort_by,
|
|
40
|
+
sort_order=sort_order,
|
|
41
|
+
)
|
|
42
|
+
return [self._to_response(user) for user in users], total
|
|
43
|
+
|
|
44
|
+
async def list_deleted_users(
|
|
45
|
+
self,
|
|
46
|
+
page: int,
|
|
47
|
+
page_size: int,
|
|
48
|
+
pagination: bool = True,
|
|
49
|
+
search: str | None = None,
|
|
50
|
+
sort_by: str | None = None,
|
|
51
|
+
sort_order: str | None = None,
|
|
52
|
+
) -> tuple[list[UserResponse], int]:
|
|
53
|
+
users, total = await self.repo.get_deleted(
|
|
54
|
+
page=page,
|
|
55
|
+
page_size=page_size,
|
|
56
|
+
pagination=pagination,
|
|
57
|
+
search=search,
|
|
58
|
+
search_columns=[User.email, User.username, User.full_name],
|
|
59
|
+
sort_by=sort_by,
|
|
60
|
+
sort_order=sort_order,
|
|
61
|
+
)
|
|
62
|
+
return [self._to_response(user) for user in users], total
|
|
63
|
+
|
|
64
|
+
async def get_user(
|
|
65
|
+
self,
|
|
66
|
+
user_id: UUID,
|
|
67
|
+
) -> UserResponse:
|
|
68
|
+
user = await self.repo.get_with_permissions(user_id)
|
|
69
|
+
if not user:
|
|
70
|
+
raise NotFoundException("User", user_id)
|
|
71
|
+
return self._to_response(user)
|
|
72
|
+
|
|
73
|
+
async def create_user(
|
|
74
|
+
self,
|
|
75
|
+
data: UserCreate,
|
|
76
|
+
current_role_name: str | None = None,
|
|
77
|
+
) -> UserResponse:
|
|
78
|
+
current_role = await self._current_role(current_role_name)
|
|
79
|
+
if data.is_superuser and current_role.name.upper() != ROOT_ROLE_NAME:
|
|
80
|
+
raise ForbiddenException("You cannot create a superuser")
|
|
81
|
+
email = data.email.lower()
|
|
82
|
+
username = data.username.lower()
|
|
83
|
+
|
|
84
|
+
if await self.repo.exists("email", email):
|
|
85
|
+
raise ConflictException("An account with this email already exists")
|
|
86
|
+
if await self.repo.exists("username", username):
|
|
87
|
+
raise ConflictException("Username is already taken")
|
|
88
|
+
|
|
89
|
+
role = await self._resolve_role(data.role_id)
|
|
90
|
+
await self._require_manage_role(current_role, role)
|
|
91
|
+
extra_permissions = await self._resolve_extra_permissions(
|
|
92
|
+
data.extra_permission_ids,
|
|
93
|
+
role=role,
|
|
94
|
+
reject_role_duplicates=False,
|
|
95
|
+
)
|
|
96
|
+
user = await self.repo.create(
|
|
97
|
+
{
|
|
98
|
+
"email": email,
|
|
99
|
+
"username": username,
|
|
100
|
+
"full_name": data.full_name,
|
|
101
|
+
"hashed_password": hash_password(data.password),
|
|
102
|
+
"is_active": data.is_active,
|
|
103
|
+
"is_superuser": data.is_superuser,
|
|
104
|
+
"is_verified": data.is_verified,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
user.roles = [role]
|
|
108
|
+
user.extra_permissions = extra_permissions
|
|
109
|
+
self.repo.session.add(user)
|
|
110
|
+
await self.repo.session.flush()
|
|
111
|
+
return self._to_response(user)
|
|
112
|
+
|
|
113
|
+
async def update_user(
|
|
114
|
+
self,
|
|
115
|
+
user_id: UUID,
|
|
116
|
+
data: UserUpdate,
|
|
117
|
+
current_role_name: str | None = None,
|
|
118
|
+
) -> UserResponse:
|
|
119
|
+
current_role = await self._current_role(current_role_name)
|
|
120
|
+
user = await self.repo.get_with_permissions(user_id)
|
|
121
|
+
if not user:
|
|
122
|
+
raise NotFoundException("User", user_id)
|
|
123
|
+
target_role = self.repo.get_role(user)
|
|
124
|
+
if user.is_superuser and current_role.name.upper() != ROOT_ROLE_NAME:
|
|
125
|
+
raise ForbiddenException("You cannot manage a superuser")
|
|
126
|
+
if target_role:
|
|
127
|
+
await self._require_manage_role(current_role, target_role)
|
|
128
|
+
|
|
129
|
+
values = data.model_dump(exclude_unset=True)
|
|
130
|
+
if "email" in values:
|
|
131
|
+
values["email"] = values["email"].lower()
|
|
132
|
+
if await self.repo.exists_for_other_user("email", values["email"], user_id):
|
|
133
|
+
raise ConflictException("An account with this email already exists")
|
|
134
|
+
|
|
135
|
+
if "username" in values:
|
|
136
|
+
values["username"] = values["username"].lower()
|
|
137
|
+
if await self.repo.exists_for_other_user(
|
|
138
|
+
"username", values["username"], user_id
|
|
139
|
+
):
|
|
140
|
+
raise ConflictException("Username is already taken")
|
|
141
|
+
if (
|
|
142
|
+
values.get("is_superuser") is True
|
|
143
|
+
and current_role.name.upper() != ROOT_ROLE_NAME
|
|
144
|
+
):
|
|
145
|
+
raise ForbiddenException("You cannot create a superuser")
|
|
146
|
+
|
|
147
|
+
role_id = values.pop("role_id", None)
|
|
148
|
+
extra_permission_ids = values.pop("extra_permission_ids", None)
|
|
149
|
+
if password := values.pop("password", None):
|
|
150
|
+
values["hashed_password"] = hash_password(password)
|
|
151
|
+
|
|
152
|
+
user = await self.repo.update(user, values)
|
|
153
|
+
role = self.repo.get_role(user)
|
|
154
|
+
if role_id is not None:
|
|
155
|
+
role = await self._resolve_role(role_id)
|
|
156
|
+
await self._require_manage_role(current_role, role)
|
|
157
|
+
user.roles = [role]
|
|
158
|
+
if role is None:
|
|
159
|
+
raise ValidationException("User must have exactly one role")
|
|
160
|
+
if extra_permission_ids is not None:
|
|
161
|
+
user.extra_permissions = await self._resolve_extra_permissions(
|
|
162
|
+
extra_permission_ids,
|
|
163
|
+
role=role,
|
|
164
|
+
reject_role_duplicates=True,
|
|
165
|
+
)
|
|
166
|
+
elif role_id is not None:
|
|
167
|
+
self._reject_role_permission_duplicates(role, user.extra_permissions)
|
|
168
|
+
|
|
169
|
+
self.repo.session.add(user)
|
|
170
|
+
await self.repo.session.flush()
|
|
171
|
+
return self._to_response(user)
|
|
172
|
+
|
|
173
|
+
async def soft_delete_user(self, user_id: UUID, note: str) -> None:
|
|
174
|
+
user = await self.repo.get_by_id(user_id)
|
|
175
|
+
if not user:
|
|
176
|
+
raise NotFoundException("User", user_id)
|
|
177
|
+
await self.repo.soft_delete(user, note)
|
|
178
|
+
|
|
179
|
+
async def hard_delete_user(self, user_id: UUID, note: str) -> None:
|
|
180
|
+
user = await self.repo.get_by_id(user_id, include_deleted=True)
|
|
181
|
+
if not user:
|
|
182
|
+
raise NotFoundException("User", user_id)
|
|
183
|
+
await self.repo.hard_delete(user, note)
|
|
184
|
+
|
|
185
|
+
def _to_response(self, user) -> UserResponse:
|
|
186
|
+
role = self.repo.get_role(user)
|
|
187
|
+
return UserResponse(
|
|
188
|
+
id=str(user.id),
|
|
189
|
+
email=user.email,
|
|
190
|
+
username=user.username,
|
|
191
|
+
full_name=user.full_name,
|
|
192
|
+
is_active=user.is_active,
|
|
193
|
+
is_superuser=user.is_superuser,
|
|
194
|
+
is_verified=user.is_verified,
|
|
195
|
+
last_login_at=(
|
|
196
|
+
user.last_login_at.isoformat() if user.last_login_at else None
|
|
197
|
+
),
|
|
198
|
+
is_deleted=user.is_deleted,
|
|
199
|
+
deleted_at=user.deleted_at.isoformat() if user.deleted_at else None,
|
|
200
|
+
deletion_note=user.deletion_note,
|
|
201
|
+
role_id=str(role.id) if role else None,
|
|
202
|
+
role=role.name if role else None,
|
|
203
|
+
role_permissions=self.repo.collect_role_permissions(user),
|
|
204
|
+
extra_permissions=self.repo.collect_extra_permissions(user),
|
|
205
|
+
permissions=self.repo.collect_permissions(user),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def _resolve_role(self, role_id: UUID):
|
|
209
|
+
role = await self.repo.get_active_role(role_id)
|
|
210
|
+
if not role:
|
|
211
|
+
raise NotFoundException("Role", role_id)
|
|
212
|
+
return role
|
|
213
|
+
|
|
214
|
+
async def _current_role(self, role_name: str | None):
|
|
215
|
+
if not role_name:
|
|
216
|
+
raise ForbiddenException("Current user role is required")
|
|
217
|
+
role = await self.roles.get_by_name_with_permissions(role_name)
|
|
218
|
+
if not role:
|
|
219
|
+
raise ForbiddenException("Current user role is invalid")
|
|
220
|
+
return role
|
|
221
|
+
|
|
222
|
+
async def _require_manage_role(self, current_role, target_role) -> None:
|
|
223
|
+
if current_role.name.upper() == ROOT_ROLE_NAME:
|
|
224
|
+
if target_role.id == current_role.id:
|
|
225
|
+
raise ForbiddenException("You cannot manage your own role")
|
|
226
|
+
return
|
|
227
|
+
if not await self.roles.is_descendant(target_role.id, current_role.id):
|
|
228
|
+
raise ForbiddenException("You cannot manage this user's role")
|
|
229
|
+
|
|
230
|
+
async def _resolve_extra_permissions(
|
|
231
|
+
self,
|
|
232
|
+
permission_ids: list[UUID],
|
|
233
|
+
role,
|
|
234
|
+
reject_role_duplicates: bool,
|
|
235
|
+
) -> list:
|
|
236
|
+
normalized = list(dict.fromkeys(permission_ids))
|
|
237
|
+
permissions = await self.repo.list_active_permissions_by_ids(normalized)
|
|
238
|
+
found = {permission.id for permission in permissions}
|
|
239
|
+
missing = [
|
|
240
|
+
str(permission_id)
|
|
241
|
+
for permission_id in normalized
|
|
242
|
+
if permission_id not in found
|
|
243
|
+
]
|
|
244
|
+
if missing:
|
|
245
|
+
raise NotFoundException("Permission", ", ".join(missing))
|
|
246
|
+
|
|
247
|
+
role_permission_ids = {
|
|
248
|
+
permission.id
|
|
249
|
+
for permission in role.permissions
|
|
250
|
+
if not permission.is_deleted and permission.is_active
|
|
251
|
+
}
|
|
252
|
+
duplicates = [
|
|
253
|
+
permission
|
|
254
|
+
for permission in permissions
|
|
255
|
+
if permission.id in role_permission_ids
|
|
256
|
+
]
|
|
257
|
+
if duplicates and reject_role_duplicates:
|
|
258
|
+
duplicate_codes = ", ".join(
|
|
259
|
+
sorted(permission.code for permission in duplicates)
|
|
260
|
+
)
|
|
261
|
+
raise ValidationException(
|
|
262
|
+
f"Permission(s) already assigned to role: {duplicate_codes}"
|
|
263
|
+
)
|
|
264
|
+
if duplicates:
|
|
265
|
+
permissions = [
|
|
266
|
+
permission
|
|
267
|
+
for permission in permissions
|
|
268
|
+
if permission.id not in role_permission_ids
|
|
269
|
+
]
|
|
270
|
+
return permissions
|
|
271
|
+
|
|
272
|
+
def _reject_role_permission_duplicates(self, role, extra_permissions: list) -> None:
|
|
273
|
+
role_permission_ids = {
|
|
274
|
+
permission.id
|
|
275
|
+
for permission in role.permissions
|
|
276
|
+
if not permission.is_deleted and permission.is_active
|
|
277
|
+
}
|
|
278
|
+
duplicates = [
|
|
279
|
+
permission
|
|
280
|
+
for permission in extra_permissions
|
|
281
|
+
if permission.id in role_permission_ids
|
|
282
|
+
and not permission.is_deleted
|
|
283
|
+
and permission.is_active
|
|
284
|
+
]
|
|
285
|
+
if duplicates:
|
|
286
|
+
duplicate_codes = ", ".join(
|
|
287
|
+
sorted(permission.code for permission in duplicates)
|
|
288
|
+
)
|
|
289
|
+
raise ValidationException(
|
|
290
|
+
f"Permission(s) already assigned to role: {duplicate_codes}"
|
|
291
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from pydantic import field_validator, model_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
from sqlalchemy.engine import URL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
APP_NAME: str = "__PROJECT_NAME__"
|
|
12
|
+
APP_VERSION: str = "1.0.0"
|
|
13
|
+
APP_ENV: str = "development"
|
|
14
|
+
APP_DEBUG: bool = False
|
|
15
|
+
SECRET_KEY: str = "change-me-in-production"
|
|
16
|
+
ALLOWED_HOSTS: List[str] = ["*"]
|
|
17
|
+
ALLOWED_ORIGINS: List[str] = ["*"]
|
|
18
|
+
log_dir: str = "logs"
|
|
19
|
+
log_to_files: bool = False
|
|
20
|
+
|
|
21
|
+
pg_host: str = "localhost"
|
|
22
|
+
pg_port: int = 5432
|
|
23
|
+
pg_database: str = "project_name"
|
|
24
|
+
pg_user: str = "postgres"
|
|
25
|
+
pg_password: str = "postgres"
|
|
26
|
+
pg_sslmode: str = "prefer"
|
|
27
|
+
database_pool_size: int = 10
|
|
28
|
+
database_max_overflow: int = 20
|
|
29
|
+
database_pool_timeout: int = 30
|
|
30
|
+
database_debug: bool = False
|
|
31
|
+
|
|
32
|
+
access_token_expire_minutes: int = 30
|
|
33
|
+
refresh_token_expire_days: int = 7
|
|
34
|
+
algorithm: str = "HS256"
|
|
35
|
+
password_reset_token_expire_minutes: int = 15
|
|
36
|
+
|
|
37
|
+
use_redis: bool = False
|
|
38
|
+
redis_url: str = ""
|
|
39
|
+
rate_limit_per_minute: int = 60
|
|
40
|
+
|
|
41
|
+
mail_username: str = ""
|
|
42
|
+
mail_password: str = ""
|
|
43
|
+
mail_from: str = "noreply@example.com"
|
|
44
|
+
mail_port: int = 587
|
|
45
|
+
mail_server: str = "smtp.example.com"
|
|
46
|
+
mail_starttls: bool = True
|
|
47
|
+
mail_ssl_tls: bool = False
|
|
48
|
+
mail_from_name: str = "__PROJECT_NAME__"
|
|
49
|
+
frontend_url: str = "http://localhost:3000"
|
|
50
|
+
|
|
51
|
+
default_page_size: int = 20
|
|
52
|
+
max_page_size: int = 100
|
|
53
|
+
|
|
54
|
+
model_config = SettingsConfigDict(
|
|
55
|
+
env_file=".env",
|
|
56
|
+
env_file_encoding="utf-8",
|
|
57
|
+
case_sensitive=False,
|
|
58
|
+
extra="ignore",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@field_validator("ALLOWED_HOSTS", "ALLOWED_ORIGINS", mode="before")
|
|
62
|
+
@classmethod
|
|
63
|
+
def parse_json_or_csv_list(cls, value):
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
value = value.strip()
|
|
66
|
+
if not value:
|
|
67
|
+
return []
|
|
68
|
+
if value.startswith("["):
|
|
69
|
+
return json.loads(value)
|
|
70
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@model_validator(mode="after")
|
|
74
|
+
def validate_production_settings(self):
|
|
75
|
+
if not self.is_production:
|
|
76
|
+
return self
|
|
77
|
+
default_secrets = {
|
|
78
|
+
"change-me-in-production",
|
|
79
|
+
"replace-with-a-long-random-secret",
|
|
80
|
+
}
|
|
81
|
+
if self.SECRET_KEY in default_secrets or len(self.SECRET_KEY) < 32:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Production SECRET_KEY must be a unique value of at least 32 characters"
|
|
84
|
+
)
|
|
85
|
+
if not self.ALLOWED_HOSTS or "*" in self.ALLOWED_HOSTS:
|
|
86
|
+
raise ValueError("Production ALLOWED_HOSTS must contain explicit hostnames")
|
|
87
|
+
if not self.ALLOWED_ORIGINS or "*" in self.ALLOWED_ORIGINS:
|
|
88
|
+
raise ValueError("Production ALLOWED_ORIGINS must contain explicit origins")
|
|
89
|
+
if self.pg_sslmode in {"disable", "allow", "prefer"}:
|
|
90
|
+
raise ValueError("Production PG_SSLMODE must verify or require TLS")
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def postgres_url(self) -> str:
|
|
95
|
+
return URL.create(
|
|
96
|
+
"postgresql+psycopg",
|
|
97
|
+
username=self.pg_user,
|
|
98
|
+
password=self.pg_password,
|
|
99
|
+
host=self.pg_host,
|
|
100
|
+
port=self.pg_port,
|
|
101
|
+
database=self.pg_database,
|
|
102
|
+
query={"sslmode": self.pg_sslmode},
|
|
103
|
+
).render_as_string(hide_password=False)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def postgres_async_url(self) -> str:
|
|
107
|
+
return URL.create(
|
|
108
|
+
"postgresql+asyncpg",
|
|
109
|
+
username=self.pg_user,
|
|
110
|
+
password=self.pg_password,
|
|
111
|
+
host=self.pg_host,
|
|
112
|
+
port=self.pg_port,
|
|
113
|
+
database=self.pg_database,
|
|
114
|
+
query={"ssl": self.pg_sslmode},
|
|
115
|
+
).render_as_string(hide_password=False)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_production(self) -> bool:
|
|
119
|
+
return self.APP_ENV.lower() in {"production", "prod"}
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def is_development(self) -> bool:
|
|
123
|
+
return self.APP_ENV.lower() in {"development", "dev"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@lru_cache
|
|
127
|
+
def get_settings() -> Settings:
|
|
128
|
+
return Settings()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
settings = get_settings()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator, Generator
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, Query, Request
|
|
5
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from app.api.v1.auth.repository import RevokedTokenRepository
|
|
10
|
+
from app.core.exceptions import ForbiddenException, UnauthorizedException
|
|
11
|
+
from app.core.security import decode_token
|
|
12
|
+
from app.db.session import AsyncSessionLocal, SyncSessionLocal
|
|
13
|
+
|
|
14
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
|
18
|
+
async with AsyncSessionLocal() as session:
|
|
19
|
+
try:
|
|
20
|
+
yield session
|
|
21
|
+
await session.commit()
|
|
22
|
+
except Exception:
|
|
23
|
+
await session.rollback()
|
|
24
|
+
raise
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_sync_db() -> Generator[Session, None, None]:
|
|
28
|
+
db = SyncSessionLocal()
|
|
29
|
+
try:
|
|
30
|
+
yield db
|
|
31
|
+
db.commit()
|
|
32
|
+
except Exception:
|
|
33
|
+
db.rollback()
|
|
34
|
+
raise
|
|
35
|
+
finally:
|
|
36
|
+
db.close()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_db() -> Generator[Session, None, None]:
|
|
40
|
+
yield from get_sync_db()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def get_current_user_payload(
|
|
44
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
|
45
|
+
db: AsyncSession = Depends(get_async_db),
|
|
46
|
+
) -> dict:
|
|
47
|
+
if not credentials:
|
|
48
|
+
raise UnauthorizedException("Bearer token missing")
|
|
49
|
+
|
|
50
|
+
payload = decode_token(credentials.credentials, expected_type="access")
|
|
51
|
+
jti = payload.get("jti")
|
|
52
|
+
if not jti:
|
|
53
|
+
raise UnauthorizedException("Invalid token payload")
|
|
54
|
+
if await RevokedTokenRepository(db).is_revoked(jti):
|
|
55
|
+
raise UnauthorizedException("Token has been revoked")
|
|
56
|
+
return payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def get_current_user_id(
|
|
60
|
+
payload: dict = Depends(get_current_user_payload),
|
|
61
|
+
) -> str:
|
|
62
|
+
user_id = payload.get("sub")
|
|
63
|
+
if not user_id:
|
|
64
|
+
raise UnauthorizedException("Invalid token payload")
|
|
65
|
+
return user_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _has_permission_override(payload: dict) -> bool:
|
|
69
|
+
return bool(payload.get("is_superuser") or "*" in payload.get("permissions", []))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require_permissions(*required_permissions: str):
|
|
73
|
+
async def _checker(payload: dict = Depends(get_current_user_payload)) -> dict:
|
|
74
|
+
if _has_permission_override(payload):
|
|
75
|
+
return payload
|
|
76
|
+
missing = [
|
|
77
|
+
permission
|
|
78
|
+
for permission in required_permissions
|
|
79
|
+
if permission not in set(payload.get("permissions", []))
|
|
80
|
+
]
|
|
81
|
+
if missing:
|
|
82
|
+
raise ForbiddenException(
|
|
83
|
+
f"Missing required permission(s): {', '.join(missing)}"
|
|
84
|
+
)
|
|
85
|
+
return payload
|
|
86
|
+
|
|
87
|
+
return _checker
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_any_permission(*allowed_permissions: str):
|
|
91
|
+
async def _checker(payload: dict = Depends(get_current_user_payload)) -> dict:
|
|
92
|
+
if _has_permission_override(payload):
|
|
93
|
+
return payload
|
|
94
|
+
user_permissions = set(payload.get("permissions", []))
|
|
95
|
+
if any(item in user_permissions for item in allowed_permissions):
|
|
96
|
+
return payload
|
|
97
|
+
raise ForbiddenException(
|
|
98
|
+
f"Missing one of required permission(s): {', '.join(allowed_permissions)}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return _checker
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def get_request_id(request: Request) -> str:
|
|
105
|
+
return getattr(request.state, "request_id", "")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def get_optional_user(
|
|
109
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
|
110
|
+
db: AsyncSession = Depends(get_async_db),
|
|
111
|
+
) -> Optional[dict]:
|
|
112
|
+
if not credentials:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
payload = decode_token(credentials.credentials, expected_type="access")
|
|
116
|
+
jti = payload.get("jti")
|
|
117
|
+
if not jti or await RevokedTokenRepository(db).is_revoked(jti):
|
|
118
|
+
return None
|
|
119
|
+
return payload
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SortingParams:
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
sort_by: str | None = Query(default=None, alias="sortBy"),
|
|
128
|
+
sort_order: str | None = Query(default="asc", alias="sortOrder"),
|
|
129
|
+
):
|
|
130
|
+
self.sort_by = sort_by
|
|
131
|
+
self.sort_order = sort_order
|