Compare commits
9 Commits
a2ab541b73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3199d0cd0 | |||
| d3f72962c0 | |||
| 4643a73c60 | |||
| eae947d9b6 | |||
| a2f626557e | |||
| c5827db872 | |||
| 7326cadfec | |||
| 1b10c97099 | |||
| 8434a5d226 |
37
Dockerfile
37
Dockerfile
@@ -1,25 +1,46 @@
|
|||||||
FROM python:3.11-slim
|
# Stage 1: build dependencies
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install build dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
curl \
|
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
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 application code
|
||||||
COPY . .
|
COPY app/ ./app/
|
||||||
|
COPY requirements.txt ./
|
||||||
|
|
||||||
|
# Make entrypoint
|
||||||
|
COPY entrypoint.sh .
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Wait for wizard config, then start uvicorn
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -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.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
|
from app.init_wizard import DELETED_USER_USERNAME
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
@@ -212,6 +213,86 @@ def update_user(
|
|||||||
return _user_response(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 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 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.created_by_id == old_id).update(
|
||||||
|
{"created_by_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)
|
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
@@ -223,17 +304,26 @@ def delete_user(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if current_user.id == user.id:
|
if current_user.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
# Protect built-in accounts from deletion
|
|
||||||
if user.is_admin:
|
if user.is_admin:
|
||||||
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||||
if user.username == "acc-mgr":
|
if user.username in _BUILTIN_USERNAMES:
|
||||||
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
raise HTTPException(
|
||||||
try:
|
status_code=400,
|
||||||
db.delete(user)
|
detail=f"The {user.username} account is a built-in account and cannot be deleted",
|
||||||
db.commit()
|
)
|
||||||
except IntegrityError:
|
|
||||||
db.rollback()
|
deleted_user = db.query(models.User).filter(
|
||||||
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -241,7 +331,7 @@ def delete_user(
|
|||||||
def reset_user_apikey(
|
def reset_user_apikey(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
db: Session = Depends(get_db),
|
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.
|
"""Reset (regenerate) a user's API key.
|
||||||
|
|
||||||
@@ -249,6 +339,8 @@ def reset_user_apikey(
|
|||||||
- user.reset-apikey: can reset any user's API key
|
- user.reset-apikey: can reset any user's API key
|
||||||
- user.reset-self-apikey: can reset only own API key
|
- user.reset-self-apikey: can reset only own API key
|
||||||
- admin: can reset any user's API key
|
- admin: can reset any user's API key
|
||||||
|
|
||||||
|
Accepts both OAuth2 Bearer token and X-API-Key authentication.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ _DEV_PERMISSIONS = {
|
|||||||
|
|
||||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||||
"account.create",
|
"account.create",
|
||||||
|
"user.reset-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Role definitions: (name, description, permission_set)
|
# Role definitions: (name, description, permission_set)
|
||||||
@@ -294,6 +295,39 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
|||||||
return user
|
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:
|
def run_init(db: Session) -> None:
|
||||||
"""Main initialization entry point. Reads config from shared volume."""
|
"""Main initialization entry point. Reads config from shared volume."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -318,6 +352,9 @@ def run_init(db: Session) -> None:
|
|||||||
# Built-in acc-mgr user (after roles are created)
|
# Built-in acc-mgr user (after roles are created)
|
||||||
init_acc_mgr_user(db)
|
init_acc_mgr_user(db)
|
||||||
|
|
||||||
|
# Built-in deleted-user (foreign key sink for deleted accounts)
|
||||||
|
init_deleted_user(db)
|
||||||
|
|
||||||
# Default project
|
# Default project
|
||||||
project_cfg = config.get("default_project")
|
project_cfg = config.get("default_project")
|
||||||
if project_cfg and admin_user:
|
if project_cfg and admin_user:
|
||||||
|
|||||||
Reference in New Issue
Block a user