Skip to content

Commit adf0de0

Browse files
Merge pull request #8 from EluminiIT/devin/1774626691-admin-users-roles
* feat: implement user administration with role-based access and audit logging - Add UserRole enum (comercial, juridico, financeiro, rh, pj, super_admin) - Add AuditLog model for tracking user management actions - Update CRUD operations for role-based user creation/update - Add get_current_user_manager dependency for role-based access control - Update API routes: create, update, delete users with audit logging - Only Super Admin can delete another Super Admin - Password optional on user creation (passwordless flow) - Add Alembic migration for role column and auditlog table - Update frontend: role select dropdown in AddUser/EditUser forms - Update frontend: show role labels in user table columns - Update frontend: role-based sidebar and admin page access Co-Authored-By: daniel.resgate <daniel.rider69@gmail.com> * fix: address Devin Review - privilege escalation guards, read_user_by_id role check, AuditLogPublic type - Add privilege escalation check in create_user: only Super Admin can create Super Admin - Add privilege escalation checks in update_user: only Super Admin can modify/promote to Super Admin - Fix read_user_by_id to use role-based check instead of is_superuser - Add target_user_email and performed_by_email fields to frontend AuditLogPublic type Co-Authored-By: daniel.resgate <daniel.rider69@gmail.com> * fix: remove password fields from Edit User dialog Co-Authored-By: daniel.resgate <daniel.rider69@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: daniel.resgate <daniel.rider69@gmail.com>
2 parents d47f870 + 16826a7 commit adf0de0

File tree

13 files changed

+509
-197
lines changed

13 files changed

+509
-197
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Add user role column and audit_log table
2+
3+
Revision ID: c3d4e5f6g7h8
4+
Revises: b2c3d4e5f6g7
5+
Create Date: 2026-03-27 15:00:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
import sqlmodel.sql.sqltypes
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "c3d4e5f6g7h8"
14+
down_revision = "b2c3d4e5f6g7"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# Add role column to user table with default 'comercial'
21+
op.add_column(
22+
"user",
23+
sa.Column(
24+
"role",
25+
sqlmodel.sql.sqltypes.AutoString(),
26+
nullable=False,
27+
server_default="comercial",
28+
),
29+
)
30+
31+
# Migrate existing superusers to super_admin role
32+
op.execute(
33+
"UPDATE \"user\" SET role = 'super_admin' WHERE is_superuser = true"
34+
)
35+
36+
# Create auditlog table
37+
op.create_table(
38+
"auditlog",
39+
sa.Column("id", sa.Uuid(), nullable=False),
40+
sa.Column(
41+
"action",
42+
sqlmodel.sql.sqltypes.AutoString(),
43+
nullable=False,
44+
),
45+
sa.Column("target_user_id", sa.Uuid(), nullable=False),
46+
sa.Column("performed_by_id", sa.Uuid(), nullable=False),
47+
sa.Column(
48+
"changes",
49+
sqlmodel.sql.sqltypes.AutoString(length=2000),
50+
nullable=False,
51+
server_default="",
52+
),
53+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
54+
sa.ForeignKeyConstraint(["target_user_id"], ["user.id"]),
55+
sa.ForeignKeyConstraint(["performed_by_id"], ["user.id"]),
56+
sa.PrimaryKeyConstraint("id"),
57+
)
58+
59+
60+
def downgrade():
61+
op.drop_table("auditlog")
62+
op.drop_column("user", "role")

backend/app/api/deps.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from app.core import security
1212
from app.core.config import settings
1313
from app.core.db import engine
14-
from app.models import TokenPayload, User
14+
from app.models import USER_MANAGER_ROLES, TokenPayload, User
1515

1616
reusable_oauth2 = OAuth2PasswordBearer(
1717
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
@@ -55,3 +55,12 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
5555
status_code=403, detail="The user doesn't have enough privileges"
5656
)
5757
return current_user
58+
59+
60+
def get_current_user_manager(current_user: CurrentUser) -> User:
61+
if current_user.role not in USER_MANAGER_ROLES:
62+
raise HTTPException(
63+
status_code=403,
64+
detail="The user doesn't have enough privileges to manage users",
65+
)
66+
return current_user

backend/app/api/routes/users.py

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88
from app.api.deps import (
99
CurrentUser,
1010
SessionDep,
11-
get_current_active_superuser,
11+
get_current_user_manager,
1212
)
1313
from app.core.config import settings
1414
from app.core.security import get_password_hash, verify_password
1515
from app.models import (
16+
AuditAction,
17+
AuditLogsPublic,
1618
Item,
1719
Message,
1820
UpdatePassword,
1921
User,
2022
UserCreate,
2123
UserPublic,
2224
UserRegister,
25+
UserRole,
2326
UsersPublic,
2427
UserUpdate,
2528
UserUpdateMe,
@@ -31,7 +34,7 @@
3134

3235
@router.get(
3336
"/",
34-
dependencies=[Depends(get_current_active_superuser)],
37+
dependencies=[Depends(get_current_user_manager)],
3538
response_model=UsersPublic,
3639
)
3740
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
@@ -51,12 +54,27 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
5154

5255

5356
@router.post(
54-
"/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
57+
"/",
58+
dependencies=[Depends(get_current_user_manager)],
59+
response_model=UserPublic,
5560
)
56-
def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
61+
def create_user(
62+
*, session: SessionDep, user_in: UserCreate, current_user: CurrentUser
63+
) -> Any:
5764
"""
58-
Create new user.
65+
Create new user. Requires email and role at minimum.
66+
Password is optional (generated automatically for passwordless flow).
5967
"""
68+
# Only Super Admin can create another Super Admin
69+
if (
70+
user_in.role == UserRole.super_admin
71+
and current_user.role != UserRole.super_admin
72+
):
73+
raise HTTPException(
74+
status_code=403,
75+
detail="Only a Super Admin can create another Super Admin",
76+
)
77+
6078
user = crud.get_user_by_email(session=session, email=user_in.email)
6179
if user:
6280
raise HTTPException(
@@ -67,13 +85,23 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
6785
user = crud.create_user(session=session, user_create=user_in)
6886
if settings.emails_enabled and user_in.email:
6987
email_data = generate_new_account_email(
70-
email_to=user_in.email, username=user_in.email, password=user_in.password
88+
email_to=user_in.email,
89+
username=user_in.email,
90+
password=user_in.password or "",
7191
)
7292
send_email(
7393
email_to=user_in.email,
7494
subject=email_data.subject,
7595
html_content=email_data.html_content,
7696
)
97+
98+
crud.create_audit_log(
99+
session=session,
100+
action=AuditAction.created,
101+
target_user_id=user.id,
102+
performed_by_id=current_user.id,
103+
changes=f"User created with role={user_in.role.value}",
104+
)
77105
return user
78106

79107

@@ -158,6 +186,19 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
158186
return user
159187

160188

189+
@router.get(
190+
"/audit-log",
191+
dependencies=[Depends(get_current_user_manager)],
192+
response_model=AuditLogsPublic,
193+
)
194+
def read_audit_logs(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
195+
"""
196+
Retrieve user audit logs.
197+
"""
198+
logs, count = crud.get_audit_logs(session=session, skip=skip, limit=limit)
199+
return AuditLogsPublic(data=logs, count=count)
200+
201+
161202
@router.get("/{user_id}", response_model=UserPublic)
162203
def read_user_by_id(
163204
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
@@ -168,7 +209,13 @@ def read_user_by_id(
168209
user = session.get(User, user_id)
169210
if user == current_user:
170211
return user
171-
if not current_user.is_superuser:
212+
if not current_user.role or current_user.role not in [
213+
UserRole.comercial,
214+
UserRole.juridico,
215+
UserRole.financeiro,
216+
UserRole.rh,
217+
UserRole.super_admin,
218+
]:
172219
raise HTTPException(
173220
status_code=403,
174221
detail="The user doesn't have enough privileges",
@@ -180,17 +227,18 @@ def read_user_by_id(
180227

181228
@router.patch(
182229
"/{user_id}",
183-
dependencies=[Depends(get_current_active_superuser)],
230+
dependencies=[Depends(get_current_user_manager)],
184231
response_model=UserPublic,
185232
)
186233
def update_user(
187234
*,
188235
session: SessionDep,
189236
user_id: uuid.UUID,
190237
user_in: UserUpdate,
238+
current_user: CurrentUser,
191239
) -> Any:
192240
"""
193-
Update a user.
241+
Update a user (role, active status, etc.).
194242
"""
195243

196244
db_user = session.get(User, user_id)
@@ -199,23 +247,74 @@ def update_user(
199247
status_code=404,
200248
detail="The user with this id does not exist in the system",
201249
)
250+
# Only Super Admin can modify a Super Admin user
251+
if (
252+
db_user.role == UserRole.super_admin
253+
and current_user.role != UserRole.super_admin
254+
):
255+
raise HTTPException(
256+
status_code=403,
257+
detail="Only a Super Admin can modify another Super Admin",
258+
)
259+
# Only Super Admin can assign the Super Admin role
260+
if (
261+
user_in.role == UserRole.super_admin
262+
and current_user.role != UserRole.super_admin
263+
):
264+
raise HTTPException(
265+
status_code=403,
266+
detail="Only a Super Admin can assign the Super Admin role",
267+
)
202268
if user_in.email:
203269
existing_user = crud.get_user_by_email(session=session, email=user_in.email)
204270
if existing_user and existing_user.id != user_id:
205271
raise HTTPException(
206272
status_code=409, detail="User with this email already exists"
207273
)
208274

275+
changes_parts = []
276+
user_data = user_in.model_dump(exclude_unset=True)
277+
if "role" in user_data and user_data["role"] is not None:
278+
changes_parts.append(f"role: {db_user.role.value} -> {user_data['role']}")
279+
if "is_active" in user_data and user_data["is_active"] is not None:
280+
changes_parts.append(
281+
f"is_active: {db_user.is_active} -> {user_data['is_active']}"
282+
)
283+
if "email" in user_data and user_data["email"] is not None:
284+
changes_parts.append(f"email: {db_user.email} -> {user_data['email']}")
285+
if "full_name" in user_data:
286+
changes_parts.append(
287+
f"full_name: {db_user.full_name} -> {user_data['full_name']}"
288+
)
289+
290+
is_deactivation = (
291+
"is_active" in user_data
292+
and user_data["is_active"] is False
293+
and db_user.is_active is True
294+
)
295+
209296
db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
297+
298+
audit_action = AuditAction.deactivated if is_deactivation else AuditAction.updated
299+
crud.create_audit_log(
300+
session=session,
301+
action=audit_action,
302+
target_user_id=db_user.id,
303+
performed_by_id=current_user.id,
304+
changes="; ".join(changes_parts) if changes_parts else "No changes",
305+
)
210306
return db_user
211307

212308

213-
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
309+
@router.delete(
310+
"/{user_id}",
311+
dependencies=[Depends(get_current_user_manager)],
312+
)
214313
def delete_user(
215314
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
216315
) -> Message:
217316
"""
218-
Delete a user.
317+
Delete a user. Only a Super Admin can delete another Super Admin.
219318
"""
220319
user = session.get(User, user_id)
221320
if not user:
@@ -224,6 +323,11 @@ def delete_user(
224323
raise HTTPException(
225324
status_code=403, detail="Super users are not allowed to delete themselves"
226325
)
326+
if user.role == UserRole.super_admin and current_user.role != UserRole.super_admin:
327+
raise HTTPException(
328+
status_code=403,
329+
detail="Only a Super Admin can delete another Super Admin",
330+
)
227331
statement = delete(Item).where(col(Item.owner_id) == user_id)
228332
session.exec(statement)
229333
session.delete(user)

backend/app/core/db.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from app import crud
44
from app.core.config import settings
5-
from app.models import User, UserCreate
5+
from app.models import User, UserCreate, UserRole
66

77
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
88

@@ -28,6 +28,6 @@ def init_db(session: Session) -> None:
2828
user_in = UserCreate(
2929
email=settings.FIRST_SUPERUSER,
3030
password=settings.FIRST_SUPERUSER_PASSWORD,
31-
is_superuser=True,
31+
role=UserRole.super_admin,
3232
)
3333
user = crud.create_user(session=session, user_create=user_in)

0 commit comments

Comments
 (0)