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)
This commit is contained in:
zhi
2026-03-22 05:39:03 +00:00
parent 8b357aabc4
commit 39d4627be1
4 changed files with 269 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
"""Auth router.""" """Auth router."""
from datetime import timedelta from datetime import timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -20,6 +22,9 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
headers={"WWW-Authenticate": "Bearer"}) headers={"WWW-Authenticate": "Bearer"})
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user") 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( access_token = create_access_token(
data={"sub": str(user.id)}, data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
@@ -30,3 +35,145 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
@router.get("/me", response_model=schemas.UserResponse) @router.get("/me", response_model=schemas.UserResponse)
async def get_me(current_user: models.User = Depends(get_current_user)): async def get_me(current_user: models.User = Depends(get_current_user)):
return 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
is_admin: bool
permissions: List[str]
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
async def get_my_permissions(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the current user's effective permissions for CLI help introspection."""
perms: List[str] = []
role_name: str | None = None
if current_user.is_admin:
# Admin gets all permissions
all_perms = db.query(Permission).order_by(Permission.name).all()
perms = [p.name for p in all_perms]
role_name = "admin"
elif current_user.role_id:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role:
role_name = role.name
perm_ids = db.query(RolePermission.permission_id).filter(
RolePermission.role_id == role.id
).all()
if perm_ids:
pid_list = [p[0] for p in perm_ids]
matched = db.query(Permission).filter(Permission.id.in_(pid_list)).order_by(Permission.name).all()
perms = [p.name for p in matched]
return PermissionIntrospectionResponse(
username=current_user.username,
role_name=role_name,
is_admin=current_user.is_admin,
permissions=perms,
)
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
is_admin: bool
permissions: List[str]
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
async def get_my_permissions(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the current user's effective permissions for CLI help introspection."""
perms: List[str] = []
role_name: str | None = None
if current_user.is_admin:
# Admin gets all permissions
all_perms = db.query(Permission).order_by(Permission.name).all()
perms = [p.name for p in all_perms]
role_name = "admin"
elif current_user.role_id:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role:
role_name = role.name
perm_ids = db.query(RolePermission.permission_id).filter(
RolePermission.role_id == role.id
).all()
if perm_ids:
pid_list = [p[0] for p in perm_ids]
matched = db.query(Permission).filter(Permission.id.in_(pid_list)).order_by(Permission.name).all()
perms = [p.name for p in matched]
return PermissionIntrospectionResponse(
username=current_user.username,
role_name=role_name,
is_admin=current_user.is_admin,
permissions=perms,
)

View File

@@ -149,10 +149,21 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db),
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) @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) query = db.query(MilestoneModel)
if project_id: 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: if status_filter:
query = query.filter(MilestoneModel.status == status_filter) query = query.filter(MilestoneModel.status == status_filter)
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()

View File

@@ -164,6 +164,11 @@ 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:
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: try:
db.delete(user) db.delete(user)
db.commit() db.commit()
@@ -173,6 +178,65 @@ def delete_user(
return None 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): class WorkLogResponse(BaseModel):
id: int id: int
task_id: int task_id: int

View File

@@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [
("account.create", "Create HarborForge accounts", "account"), ("account.create", "Create HarborForge accounts", "account"),
# User management # User management
("user.manage", "Manage users", "admin"), ("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
("monitor.read", "View monitor", "monitor"), ("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"), ("monitor.manage", "Manage monitor", "monitor"),
@@ -165,6 +168,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen", "propose.accept", "propose.reject", "propose.reopen",
"monitor.read", "monitor.read",
"user.reset-self-apikey",
} }
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose # dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
@@ -174,6 +178,7 @@ _DEV_PERMISSIONS = {
"milestone.read", "milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read", "monitor.read",
"user.reset-self-apikey",
} }
_ACCOUNT_MANAGER_PERMISSIONS = { _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)") 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: 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()
@@ -267,6 +309,9 @@ def run_init(db: Session) -> None:
if admin_user: if admin_user:
init_admin_role(db, admin_user) init_admin_role(db, admin_user)
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_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: