From 8434a5d22666205fbe3bffa2def9f456d3033132 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 15 Apr 2026 01:27:44 +0000 Subject: [PATCH 1/6] feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB Stage 1 (builder): install build deps and pre-download wheels Stage 2 (runtime): copy only installed packages + runtime deps, no build tools --- Dockerfile | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index b19a4d8..3b72f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,46 @@ -FROM python:3.11-slim +# Stage 1: build dependencies +FROM python:3.11-slim AS builder WORKDIR /app -# Install system dependencies +# Install build dependencies RUN apt-get update && apt-get install -y \ build-essential \ - curl \ default-libmysqlclient-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* +# Pre-download wheels to avoid recompiling bcrypt from source +RUN pip install --no-cache-dir --prefix=/install \ + 'bcrypt==4.0.1' \ + 'cffi>=2.0' \ + 'pycparser>=2.0' + # Install Python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# Stage 2: slim runtime +FROM python:3.11-slim + +WORKDIR /app + +# Install runtime dependencies only (no build tools) +RUN apt-get update && apt-get install -y \ + default-libmysqlclient-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy installed packages from builder +COPY --from=builder /install /usr/local # Copy application code -COPY . . +COPY app/ ./app/ +COPY requirements.txt ./ + +# Make entrypoint +COPY entrypoint.sh . RUN chmod +x entrypoint.sh -# Expose port EXPOSE 8000 - -# Wait for wizard config, then start uvicorn ENTRYPOINT ["./entrypoint.sh"] From 1b10c97099e697870909021ebb2bc9f6c808fb16 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 16 Apr 2026 21:17:13 +0000 Subject: [PATCH 2/6] feat: allow API key auth for reset-apikey endpoint Change dependency from get_current_user (OAuth2 only) to get_current_user_or_apikey, enabling account-manager API key to reset user API keys for provisioning workflows. Co-Authored-By: Claude Opus 4.6 --- app/api/routers/users.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index 1325f3e..ec64c92 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -241,7 +241,7 @@ def delete_user( def reset_user_apikey( identifier: str, db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user), + current_user: models.User = Depends(get_current_user_or_apikey), ): """Reset (regenerate) a user's API key. @@ -249,6 +249,8 @@ def reset_user_apikey( - user.reset-apikey: can reset any user's API key - user.reset-self-apikey: can reset only own API key - admin: can reset any user's API key + + Accepts both OAuth2 Bearer token and X-API-Key authentication. """ import secrets from app.models.apikey import APIKey From 7326cadfecb14b25a8144a12b23b5060aeff8106 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 16 Apr 2026 21:19:13 +0000 Subject: [PATCH 3/6] feat: grant user.reset-apikey permission to account-manager role Allows acc-mgr to reset user API keys, enabling automated provisioning workflows via the CLI. Co-Authored-By: Claude Opus 4.6 --- app/init_wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/init_wizard.py b/app/init_wizard.py index 1d094b1..b51897b 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -189,6 +189,7 @@ _DEV_PERMISSIONS = { _ACCOUNT_MANAGER_PERMISSIONS = { "account.create", + "user.reset-apikey", } # Role definitions: (name, description, permission_set) From 4643a73c60b29a42a9d553452ee3613f3f37239c Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 16 Apr 2026 23:08:19 +0100 Subject: [PATCH 4/6] 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: From d3f72962c0e88ddf9388267c3f45c5e19e3d99ff Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 16 Apr 2026 23:15:45 +0100 Subject: [PATCH 5/6] fix: correct ActivityLog import name in user deletion Co-Authored-By: Claude Opus 4.6 --- app/api/routers/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index cb2267b..b58a3a5 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -222,7 +222,7 @@ def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None: project memberships).""" from app.models.apikey import APIKey from app.models.notification import Notification - from app.models.activity import Activity + from app.models.activity import ActivityLog as Activity from app.models.worklog import WorkLog as WorkLogModel from app.models.meeting import Meeting, MeetingParticipant from app.models.task import Task From c3199d0cd066af2ab0966ba9c504583346d5c704 Mon Sep 17 00:00:00 2001 From: hzhang Date: Thu, 16 Apr 2026 23:17:32 +0100 Subject: [PATCH 6/6] fix: Essential model uses created_by_id not user_id Co-Authored-By: Claude Opus 4.6 --- app/api/routers/users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index b58a3a5..22df8f1 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -285,8 +285,8 @@ def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None: # 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}) + db.query(Essential).filter(Essential.created_by_id == old_id).update( + {"created_by_id": new_id}) # Agent profile db.query(Agent).filter(Agent.user_id == old_id).update(