api_keys.py 9.1 KB

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