gabriel / musehub public
test_identity_integration.py python
398 lines 14.8 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
1 """Integration tests for Phase 3: Auth as Code.
2
3 Tests:
4 - Expired identity returns 401 at auth time
5 - Agent identity scope is propagated to MSignContext
6 - require_scope() grants access to matching-scope agents
7 - require_scope() blocks agents missing the required scope
8 - Human identities (scope=None) bypass scope checks unconditionally
9 - require_scope() blocks with 403 (not 401) on scope failure
10 - 403 response includes the required scope name in detail
11
12 Run targeted:
13 docker compose exec musehub pytest tests/test_identity_integration.py -v
14 """
15 from __future__ import annotations
16
17 import secrets
18 import time
19 from datetime import datetime, timedelta, timezone
20 from unittest.mock import AsyncMock, MagicMock
21
22 import pytest
23 from fastapi import HTTPException
24 from httpx import AsyncClient
25 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.auth.dependencies import require_scope as dep_require_scope, TokenClaims
29 from musehub.auth.request_signing import MSignContext, _verify_msign, build_canonical_message, require_scope
30 from muse.core.types import encode_pubkey
31 from musehub.crypto.keys import b64url_encode, key_fingerprint
32 from musehub.db.musehub_auth_models import MusehubAuthKey
33 from musehub.db.musehub_identity_models import MusehubIdentity
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40 def _uid() -> str:
41 return secrets.token_hex(16)
42
43
44 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
45 priv = Ed25519PrivateKey.generate()
46 pub = priv.public_key().public_bytes_raw()
47 return priv, pub
48
49
50 def _msign_header(
51 priv: Ed25519PrivateKey,
52 handle: str,
53 method: str,
54 path: str,
55 body: bytes = b"",
56 ts: int | None = None,
57 host: str = "test",
58 ) -> str:
59 ts = ts if ts is not None else int(time.time())
60 canonical = build_canonical_message(method, path, ts, body, host=host)
61 sig_bytes = priv.sign(canonical)
62 sig_b64 = b64url_encode(sig_bytes)
63 return f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
64
65
66 async def _seed_identity(
67 session: AsyncSession,
68 handle: str,
69 priv: Ed25519PrivateKey,
70 pub: bytes,
71 *,
72 identity_type: str = "human",
73 scope: list[str] | None = None,
74 expires_at: datetime | None = None,
75 ) -> MusehubIdentity:
76 """Create a MusehubIdentity + MusehubAuthKey pair in the test DB."""
77 identity = MusehubIdentity(
78 identity_id=_uid(),
79 handle=handle,
80 identity_type=identity_type,
81 display_name=handle,
82 scope=scope,
83 expires_at=expires_at,
84 )
85 session.add(identity)
86 await session.flush()
87
88 key_row = MusehubAuthKey(
89 key_id=_uid(),
90 identity_id=identity.identity_id,
91 algorithm="ed25519",
92 public_key_b64=encode_pubkey("ed25519", pub),
93 fingerprint=key_fingerprint(pub),
94 label="test-key",
95 )
96 session.add(key_row)
97 await session.commit()
98 await session.refresh(identity)
99 return identity
100
101
102 # ---------------------------------------------------------------------------
103 # 1. Expiry enforcement (E2E via HTTP client)
104 # ---------------------------------------------------------------------------
105
106
107 async def test_expired_agent_returns_401(client: AsyncClient, db_session: AsyncSession) -> None:
108 """An expired identity is rejected with 401 regardless of key validity."""
109 priv, pub = _keypair()
110 handle = f"expired-bot-{_uid()[:8]}"
111 await _seed_identity(
112 db_session, handle, priv, pub,
113 identity_type="agent",
114 expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
115 )
116 auth = _msign_header(priv, handle, "GET", "/api/repos")
117 resp = await client.get("/api/repos", headers={"Authorization": auth})
118 assert resp.status_code == 401
119 assert "expired" in resp.json().get("detail", "").lower()
120
121
122 async def test_not_yet_expired_agent_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
123 """An agent whose expires_at is in the future passes the auth check."""
124 priv, pub = _keypair()
125 handle = f"fresh-bot-{_uid()[:8]}"
126 await _seed_identity(
127 db_session, handle, priv, pub,
128 identity_type="agent",
129 scope=["issue:write"],
130 expires_at=datetime.now(timezone.utc) + timedelta(hours=2),
131 )
132 auth = _msign_header(priv, handle, "GET", "/api/repos")
133 resp = await client.get("/api/repos", headers={"Authorization": auth})
134 # Any non-401 means the auth check passed (could be 200 or scope-gated 403)
135 assert resp.status_code != 401
136
137
138 async def test_human_without_expiry_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
139 """Human identities with no expires_at are not rejected."""
140 priv, pub = _keypair()
141 handle = f"human-noexp-{_uid()[:8]}"
142 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
143 auth = _msign_header(priv, handle, "GET", "/api/repos")
144 resp = await client.get("/api/repos", headers={"Authorization": auth})
145 assert resp.status_code != 401
146
147
148 # ---------------------------------------------------------------------------
149 # 2. Scope propagation (service layer — _verify_msign directly)
150 # ---------------------------------------------------------------------------
151
152
153 async def test_agent_scope_propagated_to_msign_context(db_session: AsyncSession) -> None:
154 """scope list from MusehubIdentity is set on MSignContext after verification."""
155 priv, pub = _keypair()
156 handle = f"scoped-agent-{_uid()[:8]}"
157 await _seed_identity(
158 db_session, handle, priv, pub,
159 identity_type="agent",
160 scope=["issue:write", "proposal:write"],
161 )
162
163 method = "GET"
164 path = "/test"
165 ts = int(time.time())
166 canonical = build_canonical_message(method, path, ts, b"", host="")
167 sig_bytes = priv.sign(canonical)
168 sig_b64 = b64url_encode(sig_bytes)
169 auth_header = f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
170
171 request = MagicMock()
172 request.headers.get.return_value = auth_header
173 request.method = method
174 # scope must be a real dict, not an unconfigured MagicMock attribute —
175 # _verify_msign reads request.scope.get("raw_path", b"") to build the
176 # signed canonical path; a bare MagicMock's .get() returns a truthy
177 # auto-mock instead of the real b"" default, so it never falls back to
178 # request.url.path below. Real Starlette Requests always have a genuine
179 # scope dict, so this only matters for this mock.
180 request.scope = {}
181 request.url.path = path
182 request.url.query = ""
183 request.url.hostname = ""
184 request.url.port = None
185 request.url.scheme = "http"
186 request.body = AsyncMock(return_value=b"")
187
188 ctx = await _verify_msign(request, db_session, required=True)
189 assert ctx is not None
190 assert ctx.scope == ["issue:write", "proposal:write"]
191 assert ctx.is_agent is True
192
193
194 async def test_human_scope_is_none_in_context(db_session: AsyncSession) -> None:
195 """Human identity with no scope column → MSignContext.scope is None."""
196 priv, pub = _keypair()
197 handle = f"human-noscope-{_uid()[:8]}"
198 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
199
200 method = "GET"
201 path = "/test"
202 ts = int(time.time())
203 canonical = build_canonical_message(method, path, ts, b"", host="")
204 sig_bytes = priv.sign(canonical)
205 sig_b64 = b64url_encode(sig_bytes)
206 auth_header = f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
207
208 request = MagicMock()
209 request.headers.get.return_value = auth_header
210 request.method = method
211 # scope must be a real dict, not an unconfigured MagicMock attribute —
212 # _verify_msign reads request.scope.get("raw_path", b"") to build the
213 # signed canonical path; a bare MagicMock's .get() returns a truthy
214 # auto-mock instead of the real b"" default, so it never falls back to
215 # request.url.path below. Real Starlette Requests always have a genuine
216 # scope dict, so this only matters for this mock.
217 request.scope = {}
218 request.url.path = path
219 request.url.query = ""
220 request.url.hostname = ""
221 request.url.port = None
222 request.url.scheme = "http"
223 request.body = AsyncMock(return_value=b"")
224
225 ctx = await _verify_msign(request, db_session, required=True)
226 assert ctx is not None
227 assert ctx.scope is None
228 assert ctx.is_agent is False
229
230
231 # ---------------------------------------------------------------------------
232 # 3. require_scope() unit logic (pure — no DB needed)
233 # ---------------------------------------------------------------------------
234
235
236 async def test_require_scope_human_scope_none_passes() -> None:
237 """require_scope() passes when claims.scope is None (human identity)."""
238 ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False, scope=None)
239 inner_dep = dep_require_scope("issue:write")
240 result = await inner_dep(claims=ctx)
241 assert result is ctx
242
243
244 async def test_require_scope_agent_matching_scope_passes() -> None:
245 """require_scope() passes when agent scope contains the required value."""
246 ctx = MSignContext(
247 handle="bot", identity_id="x", is_agent=True, is_admin=False,
248 scope=["issue:write", "proposal:write"],
249 )
250 inner_dep = dep_require_scope("issue:write")
251 result = await inner_dep(claims=ctx)
252 assert result is ctx
253
254
255 async def test_require_scope_agent_missing_scope_raises_403() -> None:
256 """require_scope() raises HTTP 403 when agent scope lacks the required value."""
257 ctx = MSignContext(
258 handle="bot", identity_id="x", is_agent=True, is_admin=False,
259 scope=["label:read"],
260 )
261 inner_dep = dep_require_scope("issue:write")
262 with pytest.raises(HTTPException) as exc_info:
263 await inner_dep(claims=ctx)
264 assert exc_info.value.status_code == 403
265 assert "issue:write" in exc_info.value.detail
266
267
268 async def test_require_scope_empty_scope_list_blocks_everything() -> None:
269 """Agent with scope=[] (empty list) is blocked from any scoped operation."""
270 ctx = MSignContext(
271 handle="bot", identity_id="x", is_agent=True, is_admin=False,
272 scope=[],
273 )
274 for required in ("issue:write", "proposal:write", "label:read", "release:write"):
275 inner_dep = dep_require_scope(required)
276 with pytest.raises(HTTPException) as exc_info:
277 await inner_dep(claims=ctx)
278 assert exc_info.value.status_code == 403
279
280
281 async def test_require_scope_returns_callable() -> None:
282 """require_scope() factory returns an awaitable callable."""
283 import inspect
284 dep = require_scope("issue:write")
285 assert inspect.iscoroutinefunction(dep)
286
287
288 # ---------------------------------------------------------------------------
289 # 4. Scope enforcement via HTTP (route-level)
290 # ---------------------------------------------------------------------------
291
292
293 async def test_agent_missing_issue_write_scope_gets_403_on_issue_create(
294 client: AsyncClient, db_session: AsyncSession
295 ) -> None:
296 """Agent without issue:write scope receives 403 when creating an issue."""
297 import json as _json
298 from tests.factories import create_repo
299
300 priv, pub = _keypair()
301 handle = f"bot-no-issue-{_uid()[:8]}"
302 await _seed_identity(
303 db_session, handle, priv, pub,
304 identity_type="agent",
305 scope=["label:read"], # intentionally missing issue:write
306 )
307 repo = await create_repo(db_session, owner=handle, visibility="public")
308
309 path = f"/api/repos/{repo.repo_id}/issues"
310 request_body = _json.dumps({"title": "Forbidden", "body": "body"}).encode()
311 auth = _msign_header(priv, handle, "POST", path, body=request_body)
312 resp = await client.post(
313 path,
314 content=request_body,
315 headers={"Authorization": auth, "Content-Type": "application/json"},
316 )
317 assert resp.status_code == 403
318 assert "issue:write" in resp.json().get("detail", "")
319
320
321 async def test_agent_with_issue_write_scope_passes_scope_check(
322 client: AsyncClient, db_session: AsyncSession
323 ) -> None:
324 """Agent with issue:write scope is not rejected by scope check on issue creation."""
325 import json as _json
326 from tests.factories import create_repo
327
328 priv, pub = _keypair()
329 handle = f"bot-issue-w-{_uid()[:8]}"
330 await _seed_identity(
331 db_session, handle, priv, pub,
332 identity_type="agent",
333 scope=["issue:write", "issue:read"],
334 )
335 repo = await create_repo(db_session, owner=handle, visibility="public")
336
337 path = f"/api/repos/{repo.repo_id}/issues"
338 request_body = _json.dumps({"title": "Agent issue", "body": "agent body"}).encode()
339 auth = _msign_header(priv, handle, "POST", path, body=request_body)
340 resp = await client.post(
341 path,
342 content=request_body,
343 headers={"Authorization": auth, "Content-Type": "application/json"},
344 )
345 # Scope check passes → 201 created (or a validation error, but NOT 403)
346 assert resp.status_code != 403
347
348
349 async def test_agent_missing_proposal_write_scope_gets_403(
350 client: AsyncClient, db_session: AsyncSession
351 ) -> None:
352 """Agent without proposal:write scope receives 403 when creating a proposal."""
353 import json as _json
354 from tests.factories import create_repo
355
356 priv, pub = _keypair()
357 handle = f"bot-no-prop-{_uid()[:8]}"
358 await _seed_identity(
359 db_session, handle, priv, pub,
360 identity_type="agent",
361 scope=["issue:write", "issue:read"], # no proposal:write
362 )
363 repo = await create_repo(db_session, owner=handle, visibility="public")
364
365 path = f"/api/repos/{repo.repo_id}/proposals"
366 request_body = _json.dumps({"title": "Test", "from_branch": "feat/x", "to_branch": "dev"}).encode()
367 auth = _msign_header(priv, handle, "POST", path, body=request_body)
368 resp = await client.post(
369 path,
370 content=request_body,
371 headers={"Authorization": auth, "Content-Type": "application/json"},
372 )
373 assert resp.status_code == 403
374 assert "proposal:write" in resp.json().get("detail", "")
375
376
377 async def test_human_passes_scope_check_on_issue_create(
378 client: AsyncClient, db_session: AsyncSession
379 ) -> None:
380 """Human identity (scope=None) bypasses scope enforcement and can create issues."""
381 import json as _json
382 from tests.factories import create_repo
383
384 priv, pub = _keypair()
385 handle = f"human-full-{_uid()[:8]}"
386 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
387 repo = await create_repo(db_session, owner=handle, visibility="public")
388
389 path = f"/api/repos/{repo.repo_id}/issues"
390 request_body = _json.dumps({"title": "Human issue", "body": "body"}).encode()
391 auth = _msign_header(priv, handle, "POST", path, body=request_body)
392 resp = await client.post(
393 path,
394 content=request_body,
395 headers={"Authorization": auth, "Content-Type": "application/json"},
396 )
397 # Human should not be blocked by scope check
398 assert resp.status_code != 403
File History 14 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:31491a5f31832312965ba9c8d73c9eb6171069015bde3a471acc9d5006c1e45a revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago