Root cause
Authentication & access control
The new permission check was attached to project deletion only, while the neighboring edit route kept its member-only dependency and still wrote a caller-supplied lead identifier.
How AI contributed: Flawed AI-written codePublished Jun 25, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
The AI-assisted hardening commit gave project deletion a permission check that reads the project lead, and left the project edit route with its member-only dependency and a writable lead field. The bypass is the pair of requests: one member request rewrites the lead, and one member request deletes the project. Release v4.6.78 closes it by running the same permission check inside the edit route before the update is written, so a member can no longer rewrite the lead and the deletion check keeps seeing the original owner.
An authenticated workspace member sends a PATCH for a project with lead_id set to their own user id, followed by a DELETE for the same project. The edit route in projects.py accepted the new lead because it required only workspace membership, and the deletion route compared the stored project lead against the caller, so the rewritten ownership satisfied the check. Both requests succeed with member rights alone, which lets a member take over and remove a project the workspace owner created.
Root cause
The new permission check was attached to project deletion only, while the neighboring edit route kept its member-only dependency and still wrote a caller-supplied lead identifier.
Fix
The edit route now loads the project and runs the same permission check before saving, so a member's attempt to change the lead is refused.
Fix by MervinPraison · no AI marker found
Case facts
@@ -93,6 +93,12 @@ async def delete_project( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session)+ project = await svc.get(project_id, workspace_id=workspace_id)+ if project is None:+ raise HTTPException(status_code=404, detail="Project not found")+ await require_delete_permission(+ workspace_id, user, session, resource_owner_id=project.lead_id+ ) deleted = await svc.delete(project_id, workspace_id=workspace_id) if not deleted: raise HTTPException(status_code=404, detail="Project not found")@@ -95,6 +95,25 @@ async def require_workspace_owner( ) +async def require_delete_permission(+ workspace_id: str,+ user: AuthIdentity,+ session: AsyncSession,+ *,+ resource_owner_id: Optional[str] = None,+) -> None:+ """Allow delete when user is admin/owner or owns the resource."""+ member_svc = MemberService(session)+ if await member_svc.has_role(workspace_id, user.id, "admin"):+ return+ if resource_owner_id and resource_owner_id == user.id:+ return+ raise HTTPException(+ status_code=status.HTTP_403_FORBIDDEN,+ detail="Insufficient permission to delete this resource",+ )++ def ensure_resource_in_workspace( resource_workspace_id: str | None, workspace_id: str,@@ -71,6 +71,12 @@ async def update_project( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session)+ project = await svc.get(project_id, workspace_id=workspace_id)+ if project is None:+ raise HTTPException(status_code=404, detail="Project not found")+ await require_delete_permission(+ workspace_id, user, session, resource_owner_id=project.lead_id+ ) project = await svc.update( project_id, workspace_id=workspace_id,