gabriel / musehub public
test_authz_phase3_sweep.py python
415 lines 17.3 KB
Raw
sha256:972eba74056d449071cb89543bfc58de94138f3826d2fd1e31491583cd8298a7 security: close 6 more write-authorization gaps (#131 Phase… Sonnet 5 minor ⚠ breaking 8 hours ago
1 """Regression tests for musehub issue #131 Phase 3 -- the full write-route sweep.
2
3 Beyond the original wire-protocol gap, the sweep found five more routes that
4 depended only on `require_scope(...)` (an agent-capability gate that passes
5 human identities through unconditionally) or on nothing at all, with zero
6 per-repo owner/collaborator check:
7
8 - POST /api/repos/{repo_id}/branches/{name}/reset (force-history-rewrite)
9 - POST/POST /api/repos/{repo_id}/proposals/{id}/close|reopen
10 - POST /api/repos/{repo_id}/sessions (+ /{id}/stop)
11 - POST /api/repos/{repo_id}/symbol-index/rebuild
12 - POST/DELETE /api/orgs/{org}/members/{handle}
13
14 Plus a smaller gap: the actor-permission lookups in collaborator management
15 (REST `collaborators.py` and the matching MCP write-tools) didn't filter
16 `accepted_at IS NOT NULL`, so a *pending* (not yet accepted) admin invite
17 could exercise admin rights before accepting.
18
19 Each test proves the *fixed* (secure) behavior: a caller with no legitimate
20 relationship to the resource must be rejected (403), while owners/admins/
21 write-collaborators must keep working.
22 """
23 from __future__ import annotations
24
25 from datetime import datetime, timezone
26
27 import pytest
28 import pytest_asyncio
29 from httpx import AsyncClient, ASGITransport
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from muse.core.types import fake_id
33 from musehub.auth.dependencies import require_valid_token
34 from musehub.auth.request_signing import MSignContext
35 from musehub.core.genesis import compute_branch_id, compute_collaborator_id, compute_identity_id
36 from musehub.db.database import get_db
37 from musehub.db.musehub_collaborator_models import MusehubCollaborator
38 from musehub.db.musehub_identity_models import MusehubIdentity
39 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
40 from musehub.main import app
41 from musehub.services.musehub_orgs import add_org_member, create_org
42 from musehub.services.musehub_proposals import create_proposal
43 from musehub.services.musehub_repository import create_repo
44
45 _OWNER = "owner-p3"
46 _OWNER_ID = compute_identity_id(_OWNER.encode())
47 _ATTACKER = "attacker-p3"
48 _ATTACKER_ID = compute_identity_id(_ATTACKER.encode())
49 _WRITE_COLLAB = "writer-p3"
50 _WRITE_COLLAB_ID = compute_identity_id(_WRITE_COLLAB.encode())
51 _ADMIN_COLLAB = "admin-p3"
52 _ADMIN_COLLAB_ID = compute_identity_id(_ADMIN_COLLAB.encode())
53
54
55 def _claims(handle: str, identity_id: str) -> MSignContext:
56 return MSignContext(handle=handle, identity_id=identity_id, is_agent=False, is_admin=False)
57
58
59 _OWNER_CLAIMS = _claims(_OWNER, _OWNER_ID)
60 _ATTACKER_CLAIMS = _claims(_ATTACKER, _ATTACKER_ID)
61 _WRITE_COLLAB_CLAIMS = _claims(_WRITE_COLLAB, _WRITE_COLLAB_ID)
62 _ADMIN_COLLAB_CLAIMS = _claims(_ADMIN_COLLAB, _ADMIN_COLLAB_ID)
63
64
65 async def _make_client(db_session: AsyncSession, claims: MSignContext) -> AsyncClient:
66 async def _override_db():
67 yield db_session
68
69 app.dependency_overrides[get_db] = _override_db
70 app.dependency_overrides[require_valid_token] = lambda: claims
71 return AsyncClient(transport=ASGITransport(app=app), base_url="https://localhost:1337")
72
73
74 async def _make_repo(db_session: AsyncSession, *, name: str, visibility: str = "private") -> MusehubRepo:
75 r = await create_repo(
76 db_session, name=name, owner=_OWNER, owner_user_id=_OWNER_ID,
77 visibility=visibility, initialize=False,
78 )
79 await db_session.commit()
80 return r
81
82
83 async def _add_collaborator(
84 db_session: AsyncSession, repo: MusehubRepo, *, handle: str, identity_id: str,
85 permission: str, accepted: bool = True,
86 ) -> None:
87 created_at = datetime.now(timezone.utc).isoformat()
88 row = MusehubCollaborator(
89 id=compute_collaborator_id(repo.repo_id, identity_id, created_at),
90 repo_id=repo.repo_id, identity_handle=handle, permission=permission,
91 )
92 db_session.add(row)
93 await db_session.flush()
94 if accepted:
95 row.accepted_at = row.invited_at
96 await db_session.commit()
97
98
99 async def _seed_identity(db_session: AsyncSession, handle: str, identity_id: str) -> None:
100 db_session.add(MusehubIdentity(
101 identity_id=identity_id, handle=handle, display_name=handle, identity_type="human",
102 ))
103 await db_session.commit()
104
105
106 async def _seed_branch_with_commits(db_session: AsyncSession, repo: MusehubRepo) -> tuple[str, str]:
107 older = fake_id(f"{repo.repo_id}-older")
108 newer = fake_id(f"{repo.repo_id}-newer")
109 now = datetime.now(timezone.utc)
110 for cid, parents in ((older, []), (newer, [older])):
111 db_session.add(MusehubCommit(
112 commit_id=cid, parent_ids=parents, snapshot_id=fake_id(cid),
113 message="m", author="a", timestamp=now, branch="main",
114 ))
115 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid))
116 db_session.add(MusehubBranch(
117 branch_id=compute_branch_id(repo.repo_id, "main"),
118 repo_id=repo.repo_id, name="main", head_commit_id=newer,
119 ))
120 await db_session.commit()
121 return older, newer
122
123
124 # ---------------------------------------------------------------------------
125 # Branch reset -- owner/admin-collaborator only
126 # ---------------------------------------------------------------------------
127
128 @pytest.mark.asyncio
129 async def test_branch_reset_rejects_non_collaborator(db_session: AsyncSession) -> None:
130 repo = await _make_repo(db_session, name="p3-reset-blocked")
131 older, _newer = await _seed_branch_with_commits(db_session, repo)
132 client = await _make_client(db_session, _ATTACKER_CLAIMS)
133 async with client:
134 resp = await client.post(
135 f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older},
136 )
137 app.dependency_overrides.clear()
138 assert resp.status_code == 403, resp.text
139
140
141 @pytest.mark.asyncio
142 async def test_branch_reset_rejects_plain_write_collaborator(db_session: AsyncSession) -> None:
143 """Reset requires *admin*, not just write -- matches the settings-change trust level."""
144 repo = await _make_repo(db_session, name="p3-reset-write-blocked")
145 older, _newer = await _seed_branch_with_commits(db_session, repo)
146 await _add_collaborator(db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_ID, permission="write")
147 client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS)
148 async with client:
149 resp = await client.post(
150 f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older},
151 )
152 app.dependency_overrides.clear()
153 assert resp.status_code == 403, resp.text
154
155
156 @pytest.mark.asyncio
157 async def test_branch_reset_allows_owner(db_session: AsyncSession) -> None:
158 repo = await _make_repo(db_session, name="p3-reset-owner-ok")
159 older, _newer = await _seed_branch_with_commits(db_session, repo)
160 client = await _make_client(db_session, _OWNER_CLAIMS)
161 async with client:
162 resp = await client.post(
163 f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older},
164 )
165 app.dependency_overrides.clear()
166 assert resp.status_code == 200, resp.text
167
168
169 # ---------------------------------------------------------------------------
170 # Proposal close/reopen -- owner-or-write/admin-collaborator
171 # ---------------------------------------------------------------------------
172
173 @pytest_asyncio.fixture()
174 async def _proposal(db_session: AsyncSession) -> tuple[MusehubRepo, str]:
175 repo = await _make_repo(db_session, name="p3-proposal-repo", visibility="public")
176 from musehub.db.musehub_repo_models import MusehubBranch as _Branch
177 now = datetime.now(timezone.utc)
178 head = fake_id(f"{repo.repo_id}-head")
179 db_session.add(MusehubCommit(
180 commit_id=head, parent_ids=[], snapshot_id=fake_id(head),
181 message="m", author=_OWNER, timestamp=now, branch="main",
182 ))
183 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=head))
184 db_session.add(_Branch(
185 branch_id=compute_branch_id(repo.repo_id, "main"),
186 repo_id=repo.repo_id, name="main", head_commit_id=head,
187 ))
188 db_session.add(_Branch(
189 branch_id=compute_branch_id(repo.repo_id, "feature"),
190 repo_id=repo.repo_id, name="feature", head_commit_id=head,
191 ))
192 await db_session.commit()
193 proposal = await create_proposal(
194 db_session, repo_id=repo.repo_id, title="t", from_branch="feature", to_branch="main",
195 author=_OWNER,
196 )
197 await db_session.commit()
198 return repo, proposal.proposal_id
199
200
201 @pytest.mark.asyncio
202 async def test_close_proposal_rejects_non_collaborator(db_session: AsyncSession, _proposal) -> None:
203 repo, proposal_id = _proposal
204 client = await _make_client(db_session, _ATTACKER_CLAIMS)
205 async with client:
206 resp = await client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close")
207 app.dependency_overrides.clear()
208 assert resp.status_code == 403, resp.text
209
210
211 @pytest.mark.asyncio
212 async def test_reopen_proposal_rejects_non_collaborator(db_session: AsyncSession, _proposal) -> None:
213 repo, proposal_id = _proposal
214 owner_client = await _make_client(db_session, _OWNER_CLAIMS)
215 async with owner_client:
216 close_resp = await owner_client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close")
217 assert close_resp.status_code == 200, close_resp.text
218 app.dependency_overrides.clear()
219
220 attacker_client = await _make_client(db_session, _ATTACKER_CLAIMS)
221 async with attacker_client:
222 resp = await attacker_client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/reopen")
223 app.dependency_overrides.clear()
224 assert resp.status_code == 403, resp.text
225
226
227 @pytest.mark.asyncio
228 async def test_close_proposal_allows_owner(db_session: AsyncSession, _proposal) -> None:
229 repo, proposal_id = _proposal
230 client = await _make_client(db_session, _OWNER_CLAIMS)
231 async with client:
232 resp = await client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close")
233 app.dependency_overrides.clear()
234 assert resp.status_code == 200, resp.text
235
236
237 # ---------------------------------------------------------------------------
238 # Sessions -- owner-or-write/admin-collaborator
239 # ---------------------------------------------------------------------------
240
241 @pytest.mark.asyncio
242 async def test_create_session_rejects_non_collaborator(db_session: AsyncSession) -> None:
243 repo = await _make_repo(db_session, name="p3-session-blocked")
244 client = await _make_client(db_session, _ATTACKER_CLAIMS)
245 async with client:
246 resp = await client.post(f"/api/repos/{repo.repo_id}/sessions", json={})
247 app.dependency_overrides.clear()
248 assert resp.status_code == 403, resp.text
249
250
251 @pytest.mark.asyncio
252 async def test_create_session_allows_write_collaborator(db_session: AsyncSession) -> None:
253 repo = await _make_repo(db_session, name="p3-session-writer-ok")
254 await _add_collaborator(db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_ID, permission="write")
255 client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS)
256 async with client:
257 resp = await client.post(f"/api/repos/{repo.repo_id}/sessions", json={})
258 app.dependency_overrides.clear()
259 assert resp.status_code == 201, resp.text
260
261
262 @pytest.mark.asyncio
263 async def test_stop_session_rejects_non_collaborator(db_session: AsyncSession) -> None:
264 repo = await _make_repo(db_session, name="p3-session-stop-blocked")
265 owner_client = await _make_client(db_session, _OWNER_CLAIMS)
266 async with owner_client:
267 create_resp = await owner_client.post(f"/api/repos/{repo.repo_id}/sessions", json={})
268 assert create_resp.status_code == 201, create_resp.text
269 session_id = create_resp.json()["sessionId"]
270 app.dependency_overrides.clear()
271
272 attacker_client = await _make_client(db_session, _ATTACKER_CLAIMS)
273 async with attacker_client:
274 resp = await attacker_client.post(
275 f"/api/repos/{repo.repo_id}/sessions/{session_id}/stop", json={},
276 )
277 app.dependency_overrides.clear()
278 assert resp.status_code == 403, resp.text
279
280
281 # ---------------------------------------------------------------------------
282 # Symbol-index rebuild -- owner-or-write/admin-collaborator
283 # ---------------------------------------------------------------------------
284
285 @pytest.mark.asyncio
286 async def test_rebuild_symbol_index_rejects_non_collaborator(db_session: AsyncSession) -> None:
287 repo = await _make_repo(db_session, name="p3-symidx-blocked")
288 now = datetime.now(timezone.utc)
289 head = fake_id(f"{repo.repo_id}-symidx")
290 db_session.add(MusehubCommit(
291 commit_id=head, parent_ids=[], snapshot_id=fake_id(head),
292 message="m", author=_OWNER, timestamp=now, branch="main",
293 ))
294 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=head))
295 await db_session.commit()
296
297 client = await _make_client(db_session, _ATTACKER_CLAIMS)
298 async with client:
299 resp = await client.post(f"/api/repos/{repo.repo_id}/symbol-index/rebuild")
300 app.dependency_overrides.clear()
301 assert resp.status_code == 403, resp.text
302
303
304 # ---------------------------------------------------------------------------
305 # Org membership -- admin-member-only, with a bootstrap exception
306 # ---------------------------------------------------------------------------
307
308 @pytest.mark.asyncio
309 async def test_add_first_org_member_bootstrap_allowed(db_session: AsyncSession) -> None:
310 org = await create_org(
311 db_session, handle="p3-org-bootstrap", display_name="Bootstrap Org",
312 quorum=1, creator_identity_id=_OWNER_ID,
313 )
314 client = await _make_client(db_session, _OWNER_CLAIMS)
315 async with client:
316 resp = await client.post(
317 f"/api/orgs/{org.handle}/members/{_OWNER}", json={"weight": "admin"},
318 )
319 app.dependency_overrides.clear()
320 assert resp.status_code == 201, resp.text
321
322
323 @pytest.mark.asyncio
324 async def test_add_org_member_rejects_non_admin(db_session: AsyncSession) -> None:
325 org = await create_org(
326 db_session, handle="p3-org-locked", display_name="Locked Org",
327 quorum=1, creator_identity_id=_OWNER_ID,
328 )
329 await add_org_member(
330 db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin",
331 actor_identity_id=_OWNER_ID, actor_handle=_OWNER,
332 )
333 client = await _make_client(db_session, _ATTACKER_CLAIMS)
334 async with client:
335 resp = await client.post(
336 f"/api/orgs/{org.handle}/members/{_ATTACKER}", json={"weight": "admin"},
337 )
338 app.dependency_overrides.clear()
339 assert resp.status_code == 403, resp.text
340
341
342 @pytest.mark.asyncio
343 async def test_remove_org_member_rejects_non_admin(db_session: AsyncSession) -> None:
344 org = await create_org(
345 db_session, handle="p3-org-remove-locked", display_name="Locked Org 2",
346 quorum=1, creator_identity_id=_OWNER_ID,
347 )
348 await add_org_member(
349 db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin",
350 actor_identity_id=_OWNER_ID, actor_handle=_OWNER,
351 )
352 client = await _make_client(db_session, _ATTACKER_CLAIMS)
353 async with client:
354 resp = await client.delete(f"/api/orgs/{org.handle}/members/{_OWNER}")
355 app.dependency_overrides.clear()
356 assert resp.status_code == 403, resp.text
357
358
359 @pytest.mark.asyncio
360 async def test_admin_member_can_add_another_member(db_session: AsyncSession) -> None:
361 org = await create_org(
362 db_session, handle="p3-org-admin-adds", display_name="Admin Org",
363 quorum=1, creator_identity_id=_OWNER_ID,
364 )
365 await add_org_member(
366 db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin",
367 actor_identity_id=_OWNER_ID, actor_handle=_OWNER,
368 )
369 client = await _make_client(db_session, _OWNER_CLAIMS)
370 async with client:
371 resp = await client.post(
372 f"/api/orgs/{org.handle}/members/{_WRITE_COLLAB}", json={"weight": "write"},
373 )
374 app.dependency_overrides.clear()
375 assert resp.status_code == 201, resp.text
376
377
378 # ---------------------------------------------------------------------------
379 # Collaborator management -- pending (not yet accepted) admin invite must not
380 # grant admin rights
381 # ---------------------------------------------------------------------------
382
383 @pytest.mark.asyncio
384 async def test_pending_admin_collaborator_cannot_invite_others(db_session: AsyncSession) -> None:
385 repo = await _make_repo(db_session, name="p3-pending-admin-blocked")
386 await _add_collaborator(
387 db_session, repo, handle=_ADMIN_COLLAB, identity_id=_ADMIN_COLLAB_ID,
388 permission="admin", accepted=False,
389 )
390 client = await _make_client(db_session, _ADMIN_COLLAB_CLAIMS)
391 async with client:
392 resp = await client.post(
393 f"/api/repos/{repo.repo_id}/collaborators",
394 json={"handle": _WRITE_COLLAB, "permission": "write"},
395 )
396 app.dependency_overrides.clear()
397 assert resp.status_code == 403, resp.text
398
399
400 @pytest.mark.asyncio
401 async def test_accepted_admin_collaborator_can_invite_others(db_session: AsyncSession) -> None:
402 repo = await _make_repo(db_session, name="p3-accepted-admin-ok")
403 await _seed_identity(db_session, _WRITE_COLLAB, _WRITE_COLLAB_ID)
404 await _add_collaborator(
405 db_session, repo, handle=_ADMIN_COLLAB, identity_id=_ADMIN_COLLAB_ID,
406 permission="admin", accepted=True,
407 )
408 client = await _make_client(db_session, _ADMIN_COLLAB_CLAIMS)
409 async with client:
410 resp = await client.post(
411 f"/api/repos/{repo.repo_id}/collaborators",
412 json={"handle": _WRITE_COLLAB, "permission": "write"},
413 )
414 app.dependency_overrides.clear()
415 assert resp.status_code == 201, resp.text
File History 1 commit
sha256:972eba74056d449071cb89543bfc58de94138f3826d2fd1e31491583cd8298a7 security: close 6 more write-authorization gaps (#131 Phase… Sonnet 5 minor 8 hours ago