test_api_key_owner_authority_1894.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. """An API key must not out-rank the user it belongs to (#1894 follow-on).
  2. ``_check_apikey_permissions`` gated purely on the scope flags stored on the key
  3. row and never looked at the owner. Scope flags are chosen at creation time by
  4. whoever holds ``api_keys:create`` -- admin-only in the default groups, but a
  5. custom group can grant it -- so a user with, say, queue permissions could mint
  6. themselves a key with ``can_control_printer`` and stop other people's prints
  7. through it. Deactivating that user did not help either: their keys kept working
  8. with full scope authority, because nothing re-checked the owner.
  9. The gate now narrows the scope flags to what the owner may do. Two cases must
  10. NOT be conflated, and each has a test below:
  11. - ``user_id IS NULL`` -- legacy key from before per-user ownership. No owner
  12. exists to narrow against, so the flags stand alone and the key keeps working.
  13. - ``user_id`` set but the row is gone or deactivated -- the key's authority came
  14. from a user who has none. Fails closed.
  15. """
  16. import pytest
  17. from httpx import AsyncClient
  18. from sqlalchemy import select
  19. from backend.app.core.auth import generate_api_key, get_password_hash
  20. from backend.app.models.api_key import APIKey
  21. from backend.app.models.group import Group
  22. from backend.app.models.user import User
  23. # A route gated on PRINTERS_READ (can_read_status) and one gated on
  24. # PRINTERS_CONTROL (can_control_printer). Both scope flags are set on every key
  25. # built below, so any denial comes from the owner check rather than the flags.
  26. READ_ROUTE = "/api/v1/printers/"
  27. CONTROL_ROUTE = "/api/v1/printers/1/print/stop"
  28. async def _setup(async_client: AsyncClient) -> None:
  29. await async_client.post(
  30. "/api/v1/auth/setup",
  31. json={"auth_enabled": True, "admin_username": "owneradmin", "admin_password": "OwnerPass1!"},
  32. )
  33. async def _key_for(db_session, owner: User | None, **scopes) -> str:
  34. defaults = {"can_read_status": True, "can_control_printer": True, "can_queue": True}
  35. defaults.update(scopes)
  36. full_key, key_hash, key_prefix = generate_api_key()
  37. db_session.add(
  38. APIKey(
  39. name=f"key-{owner.username if owner else 'legacy'}",
  40. key_hash=key_hash,
  41. key_prefix=key_prefix,
  42. enabled=True,
  43. user_id=owner.id if owner else None,
  44. **defaults,
  45. )
  46. )
  47. await db_session.commit()
  48. return full_key
  49. async def _user(db_session, username: str, permissions: list[str], *, is_active: bool = True) -> User:
  50. group = Group(name=f"grp-{username}", description="t", permissions=permissions, is_system=False)
  51. db_session.add(group)
  52. await db_session.flush()
  53. user = User(
  54. username=username,
  55. password_hash=get_password_hash("Whatever1!"), # noqa: S106
  56. role="user",
  57. is_active=is_active,
  58. groups=[group],
  59. )
  60. db_session.add(user)
  61. await db_session.commit()
  62. return user
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_admin_owned_key_keeps_full_scope_authority(async_client: AsyncClient, db_session):
  66. """The common case must not regress -- almost every key is admin-owned."""
  67. await _setup(async_client)
  68. admin = (await db_session.execute(select(User).where(User.username == "owneradmin"))).scalar_one()
  69. key = await _key_for(db_session, admin)
  70. response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
  71. assert response.status_code == 200
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_legacy_ownerless_key_still_works(async_client: AsyncClient, db_session):
  75. """No owner to narrow against is not the same as a failed owner lookup."""
  76. await _setup(async_client)
  77. key = await _key_for(db_session, None)
  78. response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
  79. assert response.status_code == 200
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_key_cannot_exceed_its_owners_permissions(async_client: AsyncClient, db_session):
  83. """The escalation: control flags ticked, owner who may not control."""
  84. await _setup(async_client)
  85. owner = await _user(db_session, "readonly", ["printers:read"])
  86. key = await _key_for(db_session, owner)
  87. allowed = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
  88. denied = await async_client.post(CONTROL_ROUTE, headers={"X-API-Key": key})
  89. assert allowed.status_code == 200
  90. assert denied.status_code == 403
  91. assert "owner does not have" in denied.json()["detail"]
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient, db_session):
  95. """Previously the key kept working -- nothing re-checked the owner."""
  96. await _setup(async_client)
  97. owner = await _user(db_session, "gone", ["printers:read"], is_active=False)
  98. key = await _key_for(db_session, owner)
  99. response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
  100. assert response.status_code == 403
  101. assert "deactivated" in response.json()["detail"]
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session):
  105. """The dangling-row case fails closed rather than reverting to flags-only.
  106. CASCADE should prevent this, but "should" is not a gate -- if the row is
  107. ever orphaned the key must not silently regain full scope authority.
  108. """
  109. await _setup(async_client)
  110. owner = await _user(db_session, "doomed", ["printers:read"])
  111. key = await _key_for(db_session, owner)
  112. api_key = (await db_session.execute(select(APIKey).where(APIKey.user_id == owner.id))).scalar_one()
  113. api_key.user_id = 999999 # owner row that does not exist
  114. await db_session.commit()
  115. response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
  116. assert response.status_code == 403
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_bearer_path_is_gated_the_same_as_the_header(async_client: AsyncClient, db_session):
  120. """Both credential paths run the same gate; only one was ever tested."""
  121. await _setup(async_client)
  122. owner = await _user(db_session, "bearer-readonly", ["printers:read"])
  123. key = await _key_for(db_session, owner)
  124. denied = await async_client.post(CONTROL_ROUTE, headers={"Authorization": f"Bearer {key}"})
  125. assert denied.status_code == 403
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_webhook_routes_are_not_a_way_around_the_owner_check(async_client: AsyncClient, db_session):
  129. """/webhook/* reaches its scope flags by a different route than the rest.
  130. It gates on ``check_permission``, not ``_check_apikey_permissions``, so it
  131. does not inherit the owner narrowing for free. If it is missed, the same key
  132. that is refused on /printers/{id}/print/stop simply stops the print here
  133. instead, and the whole gate is decorative.
  134. """
  135. await _setup(async_client)
  136. owner = await _user(db_session, "webhook-readonly", ["printers:read"])
  137. key = await _key_for(db_session, owner)
  138. denied = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
  139. assert denied.status_code == 403
  140. assert "owner does not have" in denied.json()["detail"]
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_webhook_still_works_for_a_permitted_owner(async_client: AsyncClient, db_session):
  144. """The narrowing must not simply break every webhook caller."""
  145. await _setup(async_client)
  146. owner = await _user(db_session, "webhook-operator", ["printers:read", "printers:control"])
  147. key = await _key_for(db_session, owner)
  148. response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
  149. # There is no connected printer 1, so the handler itself fails. What
  150. # matters is that the request got that far: neither gate rejected it.
  151. assert response.status_code != 403
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_webhook_rejects_a_deactivated_owner(async_client: AsyncClient, db_session):
  155. """Fail-closed applies on this path too."""
  156. await _setup(async_client)
  157. owner = await _user(db_session, "webhook-gone", ["printers:read", "printers:control"], is_active=False)
  158. key = await _key_for(db_session, owner)
  159. response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
  160. assert response.status_code == 403
  161. assert "deactivated" in response.json()["detail"]
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_me_reports_the_narrowed_set(async_client: AsyncClient, db_session):
  165. """/auth/me and the gate must agree, including about the owner."""
  166. await _setup(async_client)
  167. owner = await _user(db_session, "narrow", ["printers:read"])
  168. key = await _key_for(db_session, owner)
  169. result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})).json()
  170. assert result["id"] == owner.id
  171. assert "printers:read" in result["permissions"]
  172. # can_control_printer is ticked on the key, but the owner cannot control.
  173. assert "printers:control" not in result["permissions"]
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_me_is_rejected_once_the_owner_is_deactivated(async_client: AsyncClient, db_session):
  177. """A dead key identifies as nothing, rather than as an anonymous key."""
  178. await _setup(async_client)
  179. owner = await _user(db_session, "me-gone", ["printers:read"], is_active=False)
  180. key = await _key_for(db_session, owner)
  181. response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})
  182. assert response.status_code == 403