From 88931d822dbf595ea34f430202973f62c02caa90 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 05:39:03 +0000 Subject: [PATCH] Fix milestones 422 + acc-mgr user + reset-apikey endpoint - Fix: /milestones?project_id= now accepts project_code (str) not just int - Add: built-in acc-mgr user created on wizard init (account-manager role, no login, undeletable) - Add: POST /users/{id}/reset-apikey with permission-based access control - Add: GET /auth/me/apikey-permissions for frontend capability check - Add: user.reset-self-apikey and user.reset-apikey permissions - Protect admin and acc-mgr accounts from deletion - Block acc-mgr from login (/auth/token returns 403) --- app/api/routers/auth.py | 33 +++++++++++++++++++++ app/api/routers/misc.py | 15 ++++++++-- app/api/routers/users.py | 64 ++++++++++++++++++++++++++++++++++++++++ app/init_wizard.py | 45 ++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index efd6196..825675c 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -24,6 +24,9 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = headers={"WWW-Authenticate": "Bearer"}) if not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") + # Built-in acc-mgr account cannot log in interactively + if user.username == "acc-mgr": + raise HTTPException(status_code=403, detail="This account cannot log in") access_token = create_access_token( data={"sub": str(user.id)}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) @@ -36,6 +39,36 @@ async def get_me(current_user: models.User = Depends(get_current_user)): return current_user +class ApiKeyPermissionResponse(BaseModel): + can_reset_self: bool + can_reset_any: bool + + +@router.get("/me/apikey-permissions", response_model=ApiKeyPermissionResponse) +async def get_apikey_permissions( + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return the current user's API key reset capabilities.""" + def _has_perm(perm_name: str) -> bool: + if current_user.is_admin: + return True + if not current_user.role_id: + return False + perm = db.query(Permission).filter(Permission.name == perm_name).first() + if not perm: + return False + return db.query(RolePermission).filter( + RolePermission.role_id == current_user.role_id, + RolePermission.permission_id == perm.id, + ).first() is not None + + return ApiKeyPermissionResponse( + can_reset_self=_has_perm("user.reset-self-apikey"), + can_reset_any=_has_perm("user.reset-apikey"), + ) + + class PermissionIntrospectionResponse(BaseModel): username: str role_name: str | None diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index aa757d1..cfb37de 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -136,10 +136,21 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), @router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) -def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): +def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)): query = db.query(MilestoneModel) if project_id: - query = query.filter(MilestoneModel.project_id == project_id) + # Resolve project_id by numeric id or project_code + resolved_project = None + try: + pid = int(project_id) + resolved_project = db.query(models.Project).filter(models.Project.id == pid).first() + except (ValueError, TypeError): + pass + if not resolved_project: + resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first() + if not resolved_project: + raise HTTPException(status_code=404, detail="Project not found") + query = query.filter(MilestoneModel.project_id == resolved_project.id) if status_filter: query = query.filter(MilestoneModel.status == status_filter) return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() diff --git a/app/api/routers/users.py b/app/api/routers/users.py index fb764f3..0aefad1 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -173,6 +173,11 @@ 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() @@ -182,6 +187,65 @@ def delete_user( return None +@router.post("/{identifier}/reset-apikey") +def reset_user_apikey( + identifier: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Reset (regenerate) a user's API key. + + Permission rules: + - 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 + """ + import secrets + from app.models.apikey import APIKey + + target_user = _find_user_by_id_or_username(db, identifier) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + is_self = current_user.id == target_user.id + can_reset_any = _has_global_permission(db, current_user, "user.reset-apikey") + can_reset_self = _has_global_permission(db, current_user, "user.reset-self-apikey") + + if not (can_reset_any or (is_self and can_reset_self)): + raise HTTPException(status_code=403, detail="API key reset permission required") + + # Find existing active API key for target user, or create one + existing_key = db.query(APIKey).filter( + APIKey.user_id == target_user.id, + APIKey.is_active == True, + ).first() + + new_key_value = secrets.token_hex(32) + + if existing_key: + # Deactivate old key + existing_key.is_active = False + db.flush() + + # Create new key + new_key = APIKey( + key=new_key_value, + name=f"{target_user.username}-key", + user_id=target_user.id, + is_active=True, + ) + db.add(new_key) + db.commit() + db.refresh(new_key) + + return { + "user_id": target_user.id, + "username": target_user.username, + "api_key": new_key_value, + "message": "API key has been reset. Please save this key — it will not be shown again.", + } + + class WorkLogResponse(BaseModel): id: int task_id: int diff --git a/app/init_wizard.py b/app/init_wizard.py index 0f33ca6..d47e473 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [ ("account.create", "Create HarborForge accounts", "account"), # User management ("user.manage", "Manage users", "admin"), + # API key management + ("user.reset-self-apikey", "Reset own API key", "user"), + ("user.reset-apikey", "Reset any user's API key", "admin"), # Monitor ("monitor.read", "View monitor", "monitor"), ("monitor.manage", "Manage monitor", "monitor"), @@ -165,6 +168,7 @@ _MGR_PERMISSIONS = { "task.close", "task.reopen_closed", "task.reopen_completed", "propose.accept", "propose.reject", "propose.reopen", "monitor.read", + "user.reset-self-apikey", } # dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose @@ -174,6 +178,7 @@ _DEV_PERMISSIONS = { "milestone.read", "task.close", "task.reopen_closed", "task.reopen_completed", "monitor.read", + "user.reset-self-apikey", } _ACCOUNT_MANAGER_PERMISSIONS = { @@ -246,6 +251,43 @@ def init_admin_role(db: Session, admin_user: models.User) -> None: logger.info("Default roles setup complete (admin, mgr, dev, guest)") +def init_acc_mgr_user(db: Session) -> models.User | None: + """Create the built-in acc-mgr user if not exists. + + This user: + - Has role 'account-manager' (can only create accounts) + - Cannot log in (no password, hashed_password=None) + - Cannot be deleted (enforced in delete endpoint) + - Is created automatically after wizard initialization + """ + username = "acc-mgr" + existing = db.query(models.User).filter(models.User.username == username).first() + if existing: + logger.info("acc-mgr user already exists (id=%d), skipping", existing.id) + return existing + + # Find account-manager role + acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first() + if not acc_mgr_role: + logger.warning("account-manager role not found, skipping acc-mgr user creation") + return None + + user = models.User( + username=username, + email="acc-mgr@harborforge.internal", + full_name="Account Manager", + hashed_password=None, # Cannot log in — no password + is_admin=False, + is_active=True, + role_id=acc_mgr_role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id) + return user + + def run_init(db: Session) -> None: """Main initialization entry point. Reads config from shared volume.""" config = load_config() @@ -267,6 +309,9 @@ def run_init(db: Session) -> None: if admin_user: init_admin_role(db, admin_user) + # Built-in acc-mgr user (after roles are created) + init_acc_mgr_user(db) + # Default project project_cfg = config.get("default_project") if project_cfg and admin_user: