Skip to content

Commit 807fc45

Browse files
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>
1 parent d47f870 commit 807fc45

File tree

13 files changed

+488
-161
lines changed

13 files changed

+488
-161
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: 80 additions & 10 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,11 +54,16 @@ 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
"""
6068
user = crud.get_user_by_email(session=session, email=user_in.email)
6169
if user:
@@ -67,13 +75,23 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
6775
user = crud.create_user(session=session, user_create=user_in)
6876
if settings.emails_enabled and user_in.email:
6977
email_data = generate_new_account_email(
70-
email_to=user_in.email, username=user_in.email, password=user_in.password
78+
email_to=user_in.email,
79+
username=user_in.email,
80+
password=user_in.password or "",
7181
)
7282
send_email(
7383
email_to=user_in.email,
7484
subject=email_data.subject,
7585
html_content=email_data.html_content,
7686
)
87+
88+
crud.create_audit_log(
89+
session=session,
90+
action=AuditAction.created,
91+
target_user_id=user.id,
92+
performed_by_id=current_user.id,
93+
changes=f"User created with role={user_in.role.value}",
94+
)
7795
return user
7896

7997

@@ -158,6 +176,19 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
158176
return user
159177

160178

179+
@router.get(
180+
"/audit-log",
181+
dependencies=[Depends(get_current_user_manager)],
182+
response_model=AuditLogsPublic,
183+
)
184+
def read_audit_logs(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
185+
"""
186+
Retrieve user audit logs.
187+
"""
188+
logs, count = crud.get_audit_logs(session=session, skip=skip, limit=limit)
189+
return AuditLogsPublic(data=logs, count=count)
190+
191+
161192
@router.get("/{user_id}", response_model=UserPublic)
162193
def read_user_by_id(
163194
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
@@ -180,17 +211,18 @@ def read_user_by_id(
180211

181212
@router.patch(
182213
"/{user_id}",
183-
dependencies=[Depends(get_current_active_superuser)],
214+
dependencies=[Depends(get_current_user_manager)],
184215
response_model=UserPublic,
185216
)
186217
def update_user(
187218
*,
188219
session: SessionDep,
189220
user_id: uuid.UUID,
190221
user_in: UserUpdate,
222+
current_user: CurrentUser,
191223
) -> Any:
192224
"""
193-
Update a user.
225+
Update a user (role, active status, etc.).
194226
"""
195227

196228
db_user = session.get(User, user_id)
@@ -206,16 +238,49 @@ def update_user(
206238
status_code=409, detail="User with this email already exists"
207239
)
208240

241+
changes_parts = []
242+
user_data = user_in.model_dump(exclude_unset=True)
243+
if "role" in user_data and user_data["role"] is not None:
244+
changes_parts.append(f"role: {db_user.role.value} -> {user_data['role']}")
245+
if "is_active" in user_data and user_data["is_active"] is not None:
246+
changes_parts.append(
247+
f"is_active: {db_user.is_active} -> {user_data['is_active']}"
248+
)
249+
if "email" in user_data and user_data["email"] is not None:
250+
changes_parts.append(f"email: {db_user.email} -> {user_data['email']}")
251+
if "full_name" in user_data:
252+
changes_parts.append(
253+
f"full_name: {db_user.full_name} -> {user_data['full_name']}"
254+
)
255+
256+
is_deactivation = (
257+
"is_active" in user_data
258+
and user_data["is_active"] is False
259+
and db_user.is_active is True
260+
)
261+
209262
db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
263+
264+
audit_action = AuditAction.deactivated if is_deactivation else AuditAction.updated
265+
crud.create_audit_log(
266+
session=session,
267+
action=audit_action,
268+
target_user_id=db_user.id,
269+
performed_by_id=current_user.id,
270+
changes="; ".join(changes_parts) if changes_parts else "No changes",
271+
)
210272
return db_user
211273

212274

213-
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
275+
@router.delete(
276+
"/{user_id}",
277+
dependencies=[Depends(get_current_user_manager)],
278+
)
214279
def delete_user(
215280
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
216281
) -> Message:
217282
"""
218-
Delete a user.
283+
Delete a user. Only a Super Admin can delete another Super Admin.
219284
"""
220285
user = session.get(User, user_id)
221286
if not user:
@@ -224,6 +289,11 @@ def delete_user(
224289
raise HTTPException(
225290
status_code=403, detail="Super users are not allowed to delete themselves"
226291
)
292+
if user.role == UserRole.super_admin and current_user.role != UserRole.super_admin:
293+
raise HTTPException(
294+
status_code=403,
295+
detail="Only a Super Admin can delete another Super Admin",
296+
)
227297
statement = delete(Item).where(col(Item.owner_id) == user_id)
228298
session.exec(statement)
229299
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)

backend/app/crud.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import secrets
12
import uuid
23
from typing import Any
34

4-
from sqlmodel import Session, select
5+
from sqlmodel import Session, col, func, select
56

67
from app.core.security import get_password_hash, verify_password
78
from app.models import (
9+
AuditAction,
10+
AuditLog,
11+
AuditLogPublic,
812
Company,
913
CompanyCreate,
1014
CompanyInvite,
@@ -14,13 +18,25 @@
1418
ItemCreate,
1519
User,
1620
UserCreate,
21+
UserRole,
1722
UserUpdate,
1823
)
1924

2025

2126
def create_user(*, session: Session, user_create: UserCreate) -> User:
27+
password = user_create.password or secrets.token_urlsafe(32)
28+
is_superuser = (
29+
user_create.role == UserRole.super_admin
30+
if hasattr(user_create, "role")
31+
else False
32+
)
2233
db_obj = User.model_validate(
23-
user_create, update={"hashed_password": get_password_hash(user_create.password)}
34+
user_create,
35+
update={
36+
"hashed_password": get_password_hash(password),
37+
"is_superuser": is_superuser,
38+
"is_active": True,
39+
},
2440
)
2541
session.add(db_obj)
2642
session.commit()
@@ -35,6 +51,8 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
3551
password = user_data["password"]
3652
hashed_password = get_password_hash(password)
3753
extra_data["hashed_password"] = hashed_password
54+
if "role" in user_data and user_data["role"] is not None:
55+
extra_data["is_superuser"] = user_data["role"] == UserRole.super_admin
3856
db_user.sqlmodel_update(user_data, update=extra_data)
3957
session.add(db_user)
4058
session.commit()
@@ -145,3 +163,56 @@ def complete_company_registration(
145163
session.commit()
146164
session.refresh(company)
147165
return company
166+
167+
168+
def create_audit_log(
169+
*,
170+
session: Session,
171+
action: AuditAction,
172+
target_user_id: uuid.UUID,
173+
performed_by_id: uuid.UUID,
174+
changes: str = "",
175+
) -> AuditLog:
176+
db_log = AuditLog(
177+
action=action,
178+
target_user_id=target_user_id,
179+
performed_by_id=performed_by_id,
180+
changes=changes,
181+
)
182+
session.add(db_log)
183+
session.commit()
184+
session.refresh(db_log)
185+
return db_log
186+
187+
188+
def get_audit_logs(
189+
*, session: Session, skip: int = 0, limit: int = 100
190+
) -> tuple[list[AuditLogPublic], int]:
191+
count_statement = select(func.count()).select_from(AuditLog)
192+
count = session.exec(count_statement).one()
193+
194+
statement = (
195+
select(AuditLog)
196+
.order_by(col(AuditLog.created_at).desc())
197+
.offset(skip)
198+
.limit(limit)
199+
)
200+
logs = session.exec(statement).all()
201+
202+
result = []
203+
for log in logs:
204+
target = session.get(User, log.target_user_id)
205+
performer = session.get(User, log.performed_by_id)
206+
result.append(
207+
AuditLogPublic(
208+
id=log.id,
209+
action=log.action,
210+
target_user_id=log.target_user_id,
211+
performed_by_id=log.performed_by_id,
212+
changes=log.changes,
213+
created_at=log.created_at,
214+
target_user_email=target.email if target else None,
215+
performed_by_email=performer.email if performer else None,
216+
)
217+
)
218+
return result, count

0 commit comments

Comments
 (0)