From 10e66f76c97c80ff685ad81f19ddca34dd66e9fd Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 3 Sep 2026 11:49:30 +0530 Subject: [PATCH] Block login access for relieved employees --- app/core/security/session_auth.py | 15 +++++++++++++++ app/modules/employees/service.py | 23 +++++++++++++++++++++++ app/ui/routes/auth.py | 18 +++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/app/core/security/session_auth.py b/app/core/security/session_auth.py index d616ed7..c960d3d 100644 --- a/app/core/security/session_auth.py +++ b/app/core/security/session_auth.py @@ -6,6 +6,7 @@ from sqlalchemy import select from app.core.db.deps import get_common_db from app.modules.core.iam.models import User +from app.modules.employees.models import Employee from app.modules.core.tenancy.models import Branch from app.modules.core.tenancy.settings_models import BranchSettings @@ -22,6 +23,20 @@ def get_current_user(request: Request, db: Session = Depends(get_common_db)) -> user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none() if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None: + request.session.pop(SESSION_USER_ID_KEY, None) + request.session.pop(SESSION_LOGIN_AT_KEY, None) + return None + + relieved_employee = db.execute( + select(Employee.id).where( + Employee.user_id == int(user.id), + Employee.tenant_id == int(user.tenant_id), + Employee.status == "relieved", + ).limit(1) + ).scalar_one_or_none() + if relieved_employee is not None: + request.session.pop(SESSION_USER_ID_KEY, None) + request.session.pop(SESSION_LOGIN_AT_KEY, None) return None # Enforce session duration from BranchSettings diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index a09e78e..92b7a02 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -298,6 +298,24 @@ def create_login_user_for_employee( return user +def _disable_relieved_employee_login(db: Session, emp: Employee) -> None: + """Revoke login for a relieved employee without deleting the user identity. + + Historical engagements, attendance, payroll, documents and audit references + continue to point to the same User row. Authentication is disabled through + allow_login, while the session/auth layer independently blocks any employee + whose HR status is relieved. + """ + if (getattr(emp, "status", "") or "").strip().lower() != "relieved": + return + user_id = getattr(emp, "user_id", None) + if not user_id: + return + linked_user = db.get(User, int(user_id)) + if linked_user is not None: + linked_user.allow_login = False + + def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> Employee: cleaned = _clean_payload(data) partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin) @@ -388,6 +406,8 @@ def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[s updated_by_user_id=actor.id, ) db.add(emp) + db.flush() + _disable_relieved_employee_login(db, emp) db.commit() db.refresh(emp) return emp @@ -426,6 +446,7 @@ def update_employee(db: Session, actor: User, emp: Employee, data: dict[str, Any setattr(emp, field, cleaned.get(field)) emp.updated_by_user_id = actor.id emp.updated_at_utc = datetime.now(timezone.utc) + _disable_relieved_employee_login(db, emp) db.commit() db.refresh(emp) return emp @@ -441,6 +462,7 @@ def change_employee_status(db: Session, actor: User, emp: Employee, status: str, emp.date_of_leaving = date_of_leaving emp.updated_by_user_id = actor.id emp.updated_at_utc = datetime.now(timezone.utc) + _disable_relieved_employee_login(db, emp) db.commit() db.refresh(emp) return emp @@ -2269,6 +2291,7 @@ def complete_offboarding_request(db: Session, user: User, req: EmployeeOffboardi employee.is_active = False employee.date_of_leaving = req.approved_relieving_date or req.requested_relieving_date or date.today() employee.updated_by_user_id = user.id + _disable_relieved_employee_login(db, employee) req.status = "completed" req.completed_by_user_id = user.id req.completed_at_utc = datetime.now(timezone.utc) diff --git a/app/ui/routes/auth.py b/app/ui/routes/auth.py index 69c5967..d1580b5 100644 --- a/app/ui/routes/auth.py +++ b/app/ui/routes/auth.py @@ -25,6 +25,7 @@ from app.modules.core.rbac.models import Permission, Role, RolePermission, UserR from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.email_integration.services import send_auth_otp_email, send_password_changed_email +from app.modules.employees.models import Employee router = APIRouter() @@ -237,10 +238,21 @@ def _post_login_redirect(must_change_password: bool, permissions: set[str], role return "/employee/dashboard" -def _is_user_login_allowed(user: User) -> tuple[bool, str | None]: +def _is_user_login_allowed(user: User, db=None) -> tuple[bool, str | None]: if not user: return False, "Invalid credentials" + if db is not None: + relieved_employee = db.execute( + select(Employee.id).where( + Employee.user_id == int(user.id), + Employee.tenant_id == int(user.tenant_id), + Employee.status == "relieved", + ).limit(1) + ).scalar_one_or_none() + if relieved_employee is not None: + return False, "Login is disabled because this employee has been relieved." + if not getattr(user, "is_active", True): return False, "User account is inactive." @@ -517,7 +529,7 @@ def _submit_login( else bool(user and not is_client_only) ) - can_login, blocked_reason = _is_user_login_allowed(user) + can_login, blocked_reason = _is_user_login_allowed(user, db) password_ok = bool(user and verify_password(password, user.password_hash)) if not user or not portal_allowed or not can_login or not password_ok: @@ -861,7 +873,7 @@ def forgot_password_submit( status_code=200, ) - can_login, _ = _is_user_login_allowed(user) + can_login, _ = _is_user_login_allowed(user, db) if not can_login: return _render_forgot_password( request,