groups.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. """Group management API routes."""
  2. from fastapi import APIRouter, Depends, HTTPException, status
  3. from sqlalchemy import select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from sqlalchemy.orm import selectinload
  6. from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
  7. from backend.app.core.database import get_db
  8. from backend.app.core.permissions import (
  9. ALL_PERMISSIONS,
  10. PERMISSION_CATEGORIES,
  11. Permission,
  12. )
  13. from backend.app.models.group import Group
  14. from backend.app.models.user import User
  15. from backend.app.schemas.group import (
  16. GroupCreate,
  17. GroupDetailResponse,
  18. GroupResponse,
  19. GroupUpdate,
  20. PermissionCategory,
  21. PermissionInfo,
  22. PermissionsListResponse,
  23. UserBrief,
  24. )
  25. router = APIRouter(prefix="/groups", tags=["groups"])
  26. # Permissions whose derived label would misdescribe what is being granted.
  27. # The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a
  28. # property of the users rather than of the response -- and an admin ticking a
  29. # box in the group editor has nothing else to go on (#1894).
  30. _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
  31. Permission.USERS_READ_SLIM: "List User Names (id + username only)",
  32. }
  33. def _permission_label(perm: Permission) -> str:
  34. """Convert permission enum to human-readable label."""
  35. if perm in _PERMISSION_LABEL_OVERRIDES:
  36. return _PERMISSION_LABEL_OVERRIDES[perm]
  37. # e.g., "printers:read" -> "Read Printers"
  38. parts = perm.value.split(":")
  39. if len(parts) == 2:
  40. resource, action = parts
  41. resource = resource.replace("_", " ").title()
  42. action = action.replace("_", " ").title()
  43. return f"{action} {resource}"
  44. return perm.value
  45. @router.get("/permissions", response_model=PermissionsListResponse)
  46. async def list_permissions(
  47. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
  48. ):
  49. """List all available permissions organized by category."""
  50. categories = []
  51. for name, perms in PERMISSION_CATEGORIES.items():
  52. categories.append(
  53. PermissionCategory(
  54. name=name,
  55. permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms],
  56. )
  57. )
  58. return PermissionsListResponse(
  59. categories=categories,
  60. all_permissions=ALL_PERMISSIONS,
  61. )
  62. @router.get("", response_model=list[GroupResponse])
  63. @router.get("/", response_model=list[GroupResponse])
  64. async def list_groups(
  65. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
  66. db: AsyncSession = Depends(get_db),
  67. ):
  68. """List all groups."""
  69. result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
  70. groups = result.scalars().all()
  71. return [
  72. GroupResponse(
  73. id=group.id,
  74. name=group.name,
  75. description=group.description,
  76. permissions=group.permissions or [],
  77. is_system=group.is_system,
  78. user_count=len(group.users),
  79. created_at=group.created_at,
  80. updated_at=group.updated_at,
  81. )
  82. for group in groups
  83. ]
  84. @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
  85. @router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
  86. async def create_group(
  87. group_data: GroupCreate,
  88. _admin: User | None = RequireAdminIfAuthEnabled(),
  89. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
  90. db: AsyncSession = Depends(get_db),
  91. ):
  92. """Create a new group."""
  93. # Check if group name already exists
  94. existing = await db.execute(select(Group).where(Group.name == group_data.name))
  95. if existing.scalar_one_or_none():
  96. raise HTTPException(
  97. status_code=status.HTTP_400_BAD_REQUEST,
  98. detail="Group name already exists",
  99. )
  100. # Validate permissions
  101. invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
  102. if invalid_perms:
  103. raise HTTPException(
  104. status_code=status.HTTP_400_BAD_REQUEST,
  105. detail=f"Invalid permissions: {', '.join(invalid_perms)}",
  106. )
  107. group = Group(
  108. name=group_data.name,
  109. description=group_data.description,
  110. permissions=group_data.permissions,
  111. is_system=False, # User-created groups are not system groups
  112. )
  113. db.add(group)
  114. await db.commit()
  115. await db.refresh(group)
  116. return GroupResponse(
  117. id=group.id,
  118. name=group.name,
  119. description=group.description,
  120. permissions=group.permissions or [],
  121. is_system=group.is_system,
  122. user_count=0,
  123. created_at=group.created_at,
  124. updated_at=group.updated_at,
  125. )
  126. @router.get("/{group_id}", response_model=GroupDetailResponse)
  127. async def get_group(
  128. group_id: int,
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
  130. db: AsyncSession = Depends(get_db),
  131. ):
  132. """Get a group by ID with user list. Read-only — gated on
  133. ``GROUPS_READ`` only."""
  134. result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
  135. group = result.scalar_one_or_none()
  136. if not group:
  137. raise HTTPException(
  138. status_code=status.HTTP_404_NOT_FOUND,
  139. detail="Group not found",
  140. )
  141. return GroupDetailResponse(
  142. id=group.id,
  143. name=group.name,
  144. description=group.description,
  145. permissions=group.permissions or [],
  146. is_system=group.is_system,
  147. user_count=len(group.users),
  148. created_at=group.created_at,
  149. updated_at=group.updated_at,
  150. users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users],
  151. )
  152. @router.patch("/{group_id}", response_model=GroupResponse)
  153. async def update_group(
  154. group_id: int,
  155. group_data: GroupUpdate,
  156. _admin: User | None = RequireAdminIfAuthEnabled(),
  157. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
  158. db: AsyncSession = Depends(get_db),
  159. ):
  160. """Update a group."""
  161. result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
  162. group = result.scalar_one_or_none()
  163. if not group:
  164. raise HTTPException(
  165. status_code=status.HTTP_404_NOT_FOUND,
  166. detail="Group not found",
  167. )
  168. # Check if updating name to one that already exists
  169. if group_data.name is not None and group_data.name != group.name:
  170. existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id))
  171. if existing.scalar_one_or_none():
  172. raise HTTPException(
  173. status_code=status.HTTP_400_BAD_REQUEST,
  174. detail="Group name already exists",
  175. )
  176. # System groups cannot have their name changed
  177. if group.is_system:
  178. raise HTTPException(
  179. status_code=status.HTTP_400_BAD_REQUEST,
  180. detail="Cannot rename system groups",
  181. )
  182. group.name = group_data.name
  183. if group_data.description is not None:
  184. group.description = group_data.description
  185. if group_data.permissions is not None:
  186. # System groups (Administrators in particular) have fixed permission
  187. # sets that the app depends on — stripping them is a denial-of-
  188. # service vector that even admin callers shouldn't trigger by
  189. # accident through the generic edit form. Mirrors the rename block
  190. # immediately above.
  191. if group.is_system:
  192. raise HTTPException(
  193. status_code=status.HTTP_400_BAD_REQUEST,
  194. detail="Cannot modify permissions of system groups",
  195. )
  196. # Validate permissions
  197. invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
  198. if invalid_perms:
  199. raise HTTPException(
  200. status_code=status.HTTP_400_BAD_REQUEST,
  201. detail=f"Invalid permissions: {', '.join(invalid_perms)}",
  202. )
  203. group.permissions = group_data.permissions
  204. await db.commit()
  205. await db.refresh(group)
  206. return GroupResponse(
  207. id=group.id,
  208. name=group.name,
  209. description=group.description,
  210. permissions=group.permissions or [],
  211. is_system=group.is_system,
  212. user_count=len(group.users),
  213. created_at=group.created_at,
  214. updated_at=group.updated_at,
  215. )
  216. @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
  217. async def delete_group(
  218. group_id: int,
  219. _admin: User | None = RequireAdminIfAuthEnabled(),
  220. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
  221. db: AsyncSession = Depends(get_db),
  222. ):
  223. """Delete a group (non-system groups only)."""
  224. result = await db.execute(select(Group).where(Group.id == group_id))
  225. group = result.scalar_one_or_none()
  226. if not group:
  227. raise HTTPException(
  228. status_code=status.HTTP_404_NOT_FOUND,
  229. detail="Group not found",
  230. )
  231. if group.is_system:
  232. raise HTTPException(
  233. status_code=status.HTTP_400_BAD_REQUEST,
  234. detail="Cannot delete system groups",
  235. )
  236. await db.delete(group)
  237. await db.commit()
  238. @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  239. async def add_user_to_group(
  240. group_id: int,
  241. user_id: int,
  242. _admin: User | None = RequireAdminIfAuthEnabled(),
  243. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
  244. db: AsyncSession = Depends(get_db),
  245. ):
  246. """Add a user to a group."""
  247. # Get group with users
  248. result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
  249. group = result.scalar_one_or_none()
  250. if not group:
  251. raise HTTPException(
  252. status_code=status.HTTP_404_NOT_FOUND,
  253. detail="Group not found",
  254. )
  255. # Get user
  256. user_result = await db.execute(select(User).where(User.id == user_id))
  257. user = user_result.scalar_one_or_none()
  258. if not user:
  259. raise HTTPException(
  260. status_code=status.HTTP_404_NOT_FOUND,
  261. detail="User not found",
  262. )
  263. # Check if user is already in group
  264. if user in group.users:
  265. raise HTTPException(
  266. status_code=status.HTTP_400_BAD_REQUEST,
  267. detail="User is already in this group",
  268. )
  269. group.users.append(user)
  270. await db.commit()
  271. @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  272. async def remove_user_from_group(
  273. group_id: int,
  274. user_id: int,
  275. _admin: User | None = RequireAdminIfAuthEnabled(),
  276. _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
  277. db: AsyncSession = Depends(get_db),
  278. ):
  279. """Remove a user from a group."""
  280. # Get group with users
  281. result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
  282. group = result.scalar_one_or_none()
  283. if not group:
  284. raise HTTPException(
  285. status_code=status.HTTP_404_NOT_FOUND,
  286. detail="Group not found",
  287. )
  288. # Get user
  289. user_result = await db.execute(select(User).where(User.id == user_id))
  290. user = user_result.scalar_one_or_none()
  291. if not user:
  292. raise HTTPException(
  293. status_code=status.HTTP_404_NOT_FOUND,
  294. detail="User not found",
  295. )
  296. # Check if user is in group
  297. if user not in group.users:
  298. raise HTTPException(
  299. status_code=status.HTTP_400_BAD_REQUEST,
  300. detail="User is not in this group",
  301. )
  302. group.users.remove(user)
  303. await db.commit()