test_read_permission_backfill_migration.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. """Migration tests for maziggy/bambuddy-security #2 — read permission OWN/ALL backfill.
  2. Pre-fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat "read all" flags.
  3. Post-fix they split into OWN/ALL. The migration in seed_default_groups must:
  4. 1. Rename legacy `archives:read` etc to `archives:read_all` on Administrators
  5. and to `archives:read_own` on every other role (fail-closed default).
  6. 2. Backfill `_own` AND `_all` variants for the Administrators group on upgrade
  7. so an upgraded install matches a fresh install's permission set.
  8. 3. Backfill `_own` variants for Operators and Viewers so they keep read access
  9. even if their stored row didn't carry the legacy flag.
  10. These regressions are the failure shape Maziggy hit on a live upgrade — the
  11. admin role ended up missing queue:read_own AND queue:read after migration.
  12. """
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import select
  16. from backend.app.core import database as _database_module
  17. from backend.app.core.database import seed_default_groups
  18. from backend.app.models.group import Group
  19. _READ_FLAGS = frozenset(
  20. {
  21. "archives:read",
  22. "archives:read_own",
  23. "archives:read_all",
  24. "library:read",
  25. "library:read_own",
  26. "library:read_all",
  27. "queue:read",
  28. "queue:read_own",
  29. "queue:read_all",
  30. }
  31. )
  32. async def _strip_and_set(group_name: str, extra: list[str] | None = None) -> None:
  33. """Strip every read flag from ``group_name`` then add ``extra`` flags.
  34. Simulates a pre-migration state where the group either had only the
  35. legacy flat permission (set ``extra=['archives:read']``) or no read
  36. permission at all (set ``extra=None``).
  37. """
  38. async with _database_module.async_session() as session:
  39. grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
  40. assert grp is not None, f"group {group_name} not pre-seeded"
  41. stripped = [p for p in (grp.permissions or []) if p not in _READ_FLAGS]
  42. stripped.extend(extra or [])
  43. grp.permissions = stripped
  44. await session.commit()
  45. async def _get_perms(group_name: str) -> set[str]:
  46. async with _database_module.async_session() as session:
  47. grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
  48. assert grp is not None
  49. return set(grp.permissions or [])
  50. # Note: ``async_client`` is depended upon (even though unused) so pytest-asyncio
  51. # uses the same event loop the conftest fixture uses for async_session(). Without
  52. # it, calling ``async_session()`` twice in one test trips an asyncpg
  53. # "got Future attached to a different loop" RuntimeError.
  54. class TestReadPermissionMigration:
  55. @pytest.mark.asyncio
  56. @pytest.mark.integration
  57. async def test_legacy_archives_read_renamed_to_all_for_administrators(self, async_client: AsyncClient):
  58. """Existing Administrators group with legacy `archives:read` → gets
  59. `archives:read_all` after seed_default_groups runs, and gets the
  60. `_own` companion backfilled too."""
  61. await seed_default_groups()
  62. await _strip_and_set("Administrators", extra=["archives:read"])
  63. await seed_default_groups()
  64. perms = await _get_perms("Administrators")
  65. # Rename happened: legacy renamed to _all
  66. assert "archives:read_all" in perms
  67. # Backfill also added _own so fresh install and upgraded install match
  68. assert "archives:read_own" in perms
  69. @pytest.mark.asyncio
  70. @pytest.mark.integration
  71. async def test_administrators_backfill_adds_all_six_read_flags(self, async_client: AsyncClient):
  72. """Even with NO legacy flags present, Administrators ends up with both
  73. OWN and ALL variants for archives / library / queue after the backfill
  74. pass. This is the case Maziggy hit — admin missing `queue:read_own`
  75. after upgrade."""
  76. await seed_default_groups()
  77. await _strip_and_set("Administrators")
  78. await seed_default_groups()
  79. perms = await _get_perms("Administrators")
  80. for needed in (
  81. "archives:read_own",
  82. "archives:read_all",
  83. "library:read_own",
  84. "library:read_all",
  85. "queue:read_own",
  86. "queue:read_all",
  87. ):
  88. assert needed in perms, f"{needed} must be backfilled for Administrators"
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_operators_backfill_adds_own_read_flags(self, async_client: AsyncClient):
  92. """Operators with no read flags get the _OWN variants backfilled
  93. (fail-closed — no _ALL)."""
  94. await seed_default_groups()
  95. await _strip_and_set("Operators")
  96. await seed_default_groups()
  97. perms = await _get_perms("Operators")
  98. assert "archives:read_own" in perms
  99. assert "library:read_own" in perms
  100. assert "queue:read_own" in perms
  101. assert "archives:read_all" not in perms
  102. assert "library:read_all" not in perms
  103. assert "queue:read_all" not in perms
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_operators_legacy_archives_read_renamed_to_own(self, async_client: AsyncClient):
  107. """Pre-PR Operators with legacy `archives:read` get the _OWN rename
  108. (fail-closed — close the IDOR, the operator can re-request _ALL via
  109. admin if cross-user visibility is genuinely needed)."""
  110. await seed_default_groups()
  111. await _strip_and_set("Operators", extra=["archives:read"])
  112. await seed_default_groups()
  113. perms = await _get_perms("Operators")
  114. assert "archives:read_own" in perms
  115. assert "archives:read_all" not in perms
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_administrators_legacy_archives_read_retained(self, async_client: AsyncClient):
  119. """Admin keeps the LEGACY `archives:read` flag — the frontend gates
  120. download / preview UI on it (ArchivesPage / FileManagerPage), and
  121. removing it on rename was leaving admin with no visible download
  122. buttons after upgrade. The new API gates use the _ALL variant which
  123. the backfill also ensures is present."""
  124. await seed_default_groups()
  125. await _strip_and_set("Administrators", extra=["archives:read"])
  126. await seed_default_groups()
  127. perms = await _get_perms("Administrators")
  128. # Both the legacy flag (for the UI) and the _all variant (for the API)
  129. # must coexist on admin.
  130. assert "archives:read" in perms
  131. assert "archives:read_all" in perms
  132. assert "archives:read_own" in perms
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_administrators_backfill_adds_legacy_read_flags(self, async_client: AsyncClient):
  136. """Admin with NO read flags at all (hand-edited or stripped role) ends
  137. up with the legacy `archives:read` / `queue:read` / `library:read`
  138. backfilled — so the UI gates work — alongside the OWN/ALL split."""
  139. await seed_default_groups()
  140. await _strip_and_set("Administrators")
  141. await seed_default_groups()
  142. perms = await _get_perms("Administrators")
  143. for needed in (
  144. "archives:read",
  145. "library:read",
  146. "queue:read",
  147. "archives:read_own",
  148. "archives:read_all",
  149. "library:read_own",
  150. "library:read_all",
  151. "queue:read_own",
  152. "queue:read_all",
  153. ):
  154. assert needed in perms, f"{needed} must be backfilled for Administrators"
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_administrators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
  158. """Admin without `orca_cloud:auth` (older custom edit) gets it
  159. backfilled — matches the fresh-install default."""
  160. await seed_default_groups()
  161. async with _database_module.async_session() as session:
  162. grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
  163. grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
  164. await session.commit()
  165. await seed_default_groups()
  166. perms = await _get_perms("Administrators")
  167. assert "orca_cloud:auth" in perms
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_operators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
  171. """Operators on upgraded installs get `orca_cloud:auth` backfilled
  172. (the new default — needed for the Slice modal's Orca Cloud preset
  173. picker)."""
  174. await seed_default_groups()
  175. async with _database_module.async_session() as session:
  176. grp = (await session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  177. grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
  178. await session.commit()
  179. await seed_default_groups()
  180. perms = await _get_perms("Operators")
  181. assert "orca_cloud:auth" in perms
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):
  185. """Viewers stay read-only — orca_cloud:auth is not added by the
  186. backfill (matches the fresh-install Viewers bootstrap, which
  187. intentionally excludes cloud-auth permissions)."""
  188. await seed_default_groups()
  189. async with _database_module.async_session() as session:
  190. grp = (await session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  191. grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
  192. await session.commit()
  193. await seed_default_groups()
  194. perms = await _get_perms("Viewers")
  195. assert "orca_cloud:auth" not in perms