From 4643a73c60b29a42a9d553452ee3613f3f37239c Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 16 Apr 2026 23:08:19 +0100 Subject: [PATCH] feat: add deleted-user builtin and safe user deletion - Add deleted-user as a built-in account (no permissions, cannot log in) created during init_wizard, protected from deletion like acc-mgr - On user delete, reassign all foreign key references to deleted-user then delete the original user, instead of failing on IntegrityError - API keys, notifications, and project memberships are deleted outright since they're meaningless without the real user Co-Authored-By: Claude Opus 4.6 --- app/api/routers/users.py | 108 +++++++++++++++++++++++++++++++++++---- app/init_wizard.py | 36 +++++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index ec64c92..cb2267b 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash from app.core.config import get_db +from app.init_wizard import DELETED_USER_USERNAME from app.models import models from app.models.agent import Agent from app.models.role_permission import Permission, Role, RolePermission @@ -212,6 +213,86 @@ def update_user( return _user_response(user) +_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} + + +def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None: + """Reassign all foreign key references from old_id to new_id, then delete + records that would be meaningless under deleted-user (api_keys, notifications, + project memberships).""" + from app.models.apikey import APIKey + from app.models.notification import Notification + from app.models.activity import Activity + from app.models.worklog import WorkLog as WorkLogModel + from app.models.meeting import Meeting, MeetingParticipant + from app.models.task import Task + from app.models.support import Support + from app.models.proposal import Proposal + from app.models.milestone import Milestone + from app.models.calendar import TimeSlot, SchedulePlan + from app.models.minimum_workload import MinimumWorkload + from app.models.essential import Essential + + # Delete records that are meaningless without the real user + db.query(APIKey).filter(APIKey.user_id == old_id).delete() + db.query(Notification).filter(Notification.user_id == old_id).delete() + db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete() + + # Reassign ownership/authorship references + db.query(models.Project).filter(models.Project.owner_id == old_id).update( + {"owner_id": new_id}) + db.query(models.Comment).filter(models.Comment.author_id == old_id).update( + {"author_id": new_id}) + db.query(Activity).filter(Activity.user_id == old_id).update( + {"user_id": new_id}) + db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update( + {"user_id": new_id}) + + # Tasks + db.query(Task).filter(Task.reporter_id == old_id).update( + {"reporter_id": new_id}) + db.query(Task).filter(Task.assignee_id == old_id).update( + {"assignee_id": new_id}) + db.query(Task).filter(Task.created_by_id == old_id).update( + {"created_by_id": new_id}) + + # Meetings + db.query(Meeting).filter(Meeting.reporter_id == old_id).update( + {"reporter_id": new_id}) + db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update( + {"user_id": new_id}) + + # Support + db.query(Support).filter(Support.reporter_id == old_id).update( + {"reporter_id": new_id}) + db.query(Support).filter(Support.assignee_id == old_id).update( + {"assignee_id": new_id}) + + # Proposals + db.query(Proposal).filter(Proposal.created_by_id == old_id).update( + {"created_by_id": new_id}) + + # Milestones + db.query(Milestone).filter(Milestone.created_by_id == old_id).update( + {"created_by_id": new_id}) + + # Calendar + db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update( + {"user_id": new_id}) + db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update( + {"user_id": new_id}) + + # Minimum workload / Essential + db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update( + {"user_id": new_id}) + db.query(Essential).filter(Essential.user_id == old_id).update( + {"user_id": new_id}) + + # Agent profile + db.query(Agent).filter(Agent.user_id == old_id).update( + {"user_id": new_id}) + + @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT) def delete_user( identifier: str, @@ -223,17 +304,26 @@ def delete_user( raise HTTPException(status_code=404, detail="User not found") if current_user.id == user.id: raise HTTPException(status_code=400, detail="You cannot delete your own account") - # Protect built-in accounts from deletion if user.is_admin: raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted") - if user.username == "acc-mgr": - raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted") - try: - db.delete(user) - db.commit() - except IntegrityError: - db.rollback() - raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.") + if user.username in _BUILTIN_USERNAMES: + raise HTTPException( + status_code=400, + detail=f"The {user.username} account is a built-in account and cannot be deleted", + ) + + deleted_user = db.query(models.User).filter( + models.User.username == DELETED_USER_USERNAME + ).first() + if not deleted_user: + raise HTTPException( + status_code=500, + detail="Built-in deleted-user account not found. Run init_wizard first.", + ) + + _reassign_user_references(db, user.id, deleted_user.id) + db.delete(user) + db.commit() return None diff --git a/app/init_wizard.py b/app/init_wizard.py index b51897b..d49594d 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -295,6 +295,39 @@ def init_acc_mgr_user(db: Session) -> models.User | None: return user +DELETED_USER_USERNAME = "deleted-user" + + +def init_deleted_user(db: Session) -> models.User | None: + """Create the built-in deleted-user if not exists. + + This user serves as a foreign key sink: when a real user is deleted, + all references are reassigned here instead of cascading deletes. + It has no role (no permissions) and cannot log in. + """ + existing = db.query(models.User).filter( + models.User.username == DELETED_USER_USERNAME + ).first() + if existing: + logger.info("deleted-user already exists (id=%d), skipping", existing.id) + return existing + + user = models.User( + username=DELETED_USER_USERNAME, + email="deleted-user@harborforge.internal", + full_name="Deleted User", + hashed_password=None, + is_admin=False, + is_active=False, + role_id=None, + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Created deleted-user (id=%d)", user.id) + return user + + def run_init(db: Session) -> None: """Main initialization entry point. Reads config from shared volume.""" config = load_config() @@ -319,6 +352,9 @@ def run_init(db: Session) -> None: # Built-in acc-mgr user (after roles are created) init_acc_mgr_user(db) + # Built-in deleted-user (foreign key sink for deleted accounts) + init_deleted_user(db) + # Default project project_cfg = config.get("default_project") if project_cfg and admin_user: