api_keys.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import logging
  2. from fastapi import APIRouter, Depends, HTTPException
  3. from sqlalchemy import delete, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.api.routes.notifications import forget_app_senders
  6. from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
  7. from backend.app.core.database import get_db
  8. from backend.app.core.permissions import Permission
  9. from backend.app.core.printer_scope import ensure_printers_exist
  10. from backend.app.core.websocket import ws_manager
  11. from backend.app.models.api_key import APIKey
  12. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  13. from backend.app.models.user import User
  14. from backend.app.schemas.api_key import (
  15. APIKeyCreate,
  16. APIKeyCreateResponse,
  17. APIKeyResponse,
  18. APIKeyUpdate,
  19. )
  20. logger = logging.getLogger(__name__)
  21. router = APIRouter(prefix="/api-keys", tags=["api-keys"])
  22. @router.get("/", response_model=list[APIKeyResponse])
  23. async def list_api_keys(
  24. db: AsyncSession = Depends(get_db),
  25. _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
  26. ):
  27. """List all API keys (without full key values)."""
  28. result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
  29. return list(result.scalars().all())
  30. @router.post("/", response_model=APIKeyCreateResponse)
  31. async def create_api_key(
  32. data: APIKeyCreate,
  33. db: AsyncSession = Depends(get_db),
  34. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
  35. ):
  36. """Create a new API key.
  37. IMPORTANT: The full API key is only returned in this response.
  38. Store it securely - it cannot be retrieved again.
  39. """
  40. # Reject can_access_cloud on auth-disabled deployments — there's no per-user
  41. # cloud_token to read against, so the flag would just silently do nothing.
  42. # Surfacing the rejection at create time prevents the user from thinking
  43. # they've configured cloud access when they actually haven't.
  44. if data.can_access_cloud and current_user is None:
  45. raise HTTPException(
  46. status_code=400,
  47. detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
  48. )
  49. await ensure_printers_exist(db, data.printer_ids)
  50. # Generate the key
  51. full_key, key_hash, key_prefix = generate_api_key()
  52. api_key = APIKey(
  53. name=data.name,
  54. key_hash=key_hash,
  55. key_prefix=key_prefix,
  56. user_id=current_user.id if current_user else None,
  57. can_queue=data.can_queue,
  58. can_control_printer=data.can_control_printer,
  59. can_read_status=data.can_read_status,
  60. can_manage_library=data.can_manage_library,
  61. can_manage_inventory=data.can_manage_inventory,
  62. can_manage_maintenance=data.can_manage_maintenance,
  63. can_manage_archives=data.can_manage_archives,
  64. can_manage_projects=data.can_manage_projects,
  65. can_access_cloud=data.can_access_cloud,
  66. can_update_energy_cost=data.can_update_energy_cost,
  67. can_send_notifications=data.can_send_notifications,
  68. printer_ids=data.printer_ids,
  69. expires_at=data.expires_at,
  70. )
  71. db.add(api_key)
  72. await db.flush()
  73. await db.refresh(api_key)
  74. # Return with full key (only time it's shown)
  75. return APIKeyCreateResponse(
  76. id=api_key.id,
  77. name=api_key.name,
  78. key_prefix=api_key.key_prefix,
  79. key=full_key, # Only returned on creation
  80. user_id=api_key.user_id,
  81. can_queue=api_key.can_queue,
  82. can_control_printer=api_key.can_control_printer,
  83. can_read_status=api_key.can_read_status,
  84. can_manage_library=api_key.can_manage_library,
  85. can_manage_inventory=api_key.can_manage_inventory,
  86. can_manage_maintenance=api_key.can_manage_maintenance,
  87. can_manage_archives=api_key.can_manage_archives,
  88. can_manage_projects=api_key.can_manage_projects,
  89. can_access_cloud=api_key.can_access_cloud,
  90. can_update_energy_cost=api_key.can_update_energy_cost,
  91. can_send_notifications=api_key.can_send_notifications,
  92. printer_ids=api_key.printer_ids,
  93. enabled=api_key.enabled,
  94. last_used=api_key.last_used,
  95. created_at=api_key.created_at,
  96. expires_at=api_key.expires_at,
  97. )
  98. @router.get("/{key_id}", response_model=APIKeyResponse)
  99. async def get_api_key(
  100. key_id: int,
  101. db: AsyncSession = Depends(get_db),
  102. _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
  103. ):
  104. """Get an API key by ID."""
  105. result = await db.execute(select(APIKey).where(APIKey.id == key_id))
  106. api_key = result.scalar_one_or_none()
  107. if not api_key:
  108. raise HTTPException(status_code=404, detail="API key not found")
  109. return api_key
  110. @router.patch("/{key_id}", response_model=APIKeyResponse)
  111. async def update_api_key(
  112. key_id: int,
  113. data: APIKeyUpdate,
  114. db: AsyncSession = Depends(get_db),
  115. _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
  116. ):
  117. """Update an API key."""
  118. result = await db.execute(select(APIKey).where(APIKey.id == key_id))
  119. api_key = result.scalar_one_or_none()
  120. if not api_key:
  121. raise HTTPException(status_code=404, detail="API key not found")
  122. # Update fields if provided
  123. if data.name is not None:
  124. api_key.name = data.name
  125. if data.can_queue is not None:
  126. api_key.can_queue = data.can_queue
  127. if data.can_control_printer is not None:
  128. api_key.can_control_printer = data.can_control_printer
  129. if data.can_read_status is not None:
  130. api_key.can_read_status = data.can_read_status
  131. if data.can_manage_library is not None:
  132. api_key.can_manage_library = data.can_manage_library
  133. if data.can_manage_inventory is not None:
  134. api_key.can_manage_inventory = data.can_manage_inventory
  135. if data.can_manage_maintenance is not None:
  136. api_key.can_manage_maintenance = data.can_manage_maintenance
  137. if data.can_manage_archives is not None:
  138. api_key.can_manage_archives = data.can_manage_archives
  139. if data.can_manage_projects is not None:
  140. api_key.can_manage_projects = data.can_manage_projects
  141. if data.can_access_cloud is not None:
  142. # Same constraint as create — flipping cloud access on a legacy key
  143. # without an owner would be silently broken; reject at the route layer.
  144. if data.can_access_cloud and api_key.user_id is None:
  145. raise HTTPException(
  146. status_code=400,
  147. detail="can_access_cloud requires the API key to have an owner; recreate the key after upgrading",
  148. )
  149. api_key.can_access_cloud = data.can_access_cloud
  150. if data.can_update_energy_cost is not None:
  151. api_key.can_update_energy_cost = data.can_update_energy_cost
  152. if data.can_send_notifications is not None:
  153. api_key.can_send_notifications = data.can_send_notifications
  154. if data.printer_ids is not None:
  155. await ensure_printers_exist(db, data.printer_ids)
  156. api_key.printer_ids = data.printer_ids
  157. if data.enabled is not None:
  158. api_key.enabled = data.enabled
  159. if data.expires_at is not None:
  160. api_key.expires_at = data.expires_at
  161. await db.flush()
  162. await db.refresh(api_key)
  163. if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
  164. # Sockets opened with this key may now see fewer printers, or none
  165. # (#1727). Committed first so the refresh reads the new row.
  166. await db.commit()
  167. await ws_manager.refresh_printer_scopes()
  168. return api_key
  169. async def drop_api_key_tokens(db: AsyncSession, key_ids) -> None:
  170. """Delete the camera and WebSocket tokens deleted keys minted (media tokens
  171. never record the key).
  172. They name the key by id, which SQLite gives to the next key created: a
  173. token of a key limited to one printer would get the new key's printers.
  174. """
  175. key_ids = list(key_ids)
  176. if key_ids:
  177. await db.execute(delete(AuthEphemeralToken).where(AuthEphemeralToken.api_key_id.in_(key_ids)))
  178. @router.delete("/{key_id}")
  179. async def delete_api_key(
  180. key_id: int,
  181. db: AsyncSession = Depends(get_db),
  182. _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
  183. ):
  184. """Delete (revoke) an API key."""
  185. result = await db.execute(select(APIKey).where(APIKey.id == key_id))
  186. api_key = result.scalar_one_or_none()
  187. if not api_key:
  188. raise HTTPException(status_code=404, detail="API key not found")
  189. await db.delete(api_key)
  190. await drop_api_key_tokens(db, [key_id])
  191. await db.commit()
  192. # Its sockets are closed, not just given no printers: their scope is looked
  193. # up by key id, which SQLite gives to the next key created.
  194. await ws_manager.close_api_keys([key_id])
  195. forget_app_senders([key_id])
  196. return {"message": "API key deleted"}