gabriel / musehub public
test_identity_integration.py python
383 lines 13.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 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 time
18 import uuid
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 musehub.crypto.keys import b64url_encode, key_fingerprint
31 from musehub.db import musehub_models as db
32 from musehub.db.musehub_auth_models import MusehubAuthKey
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39 def _uid() -> str:
40 return str(uuid.uuid4())
41
42
43 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
44 priv = Ed25519PrivateKey.generate()
45 pub = priv.public_key().public_bytes_raw()
46 return priv, pub
47
48
49 def _msign_header(
50 priv: Ed25519PrivateKey,
51 handle: str,
52 method: str,
53 path: str,
54 body: bytes = b"",
55 ts: int | None = None,
56 host: str = "test",
57 ) -> str:
58 ts = ts if ts is not None else int(time.time())
59 canonical = build_canonical_message(method, path, ts, body, host=host)
60 sig_bytes = priv.sign(canonical)
61 sig_b64 = b64url_encode(sig_bytes)
62 return f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
63
64
65 async def _seed_identity(
66 session: AsyncSession,
67 handle: str,
68 priv: Ed25519PrivateKey,
69 pub: bytes,
70 *,
71 identity_type: str = "human",
72 scope: list[str] | None = None,
73 expires_at: datetime | None = None,
74 ) -> db.MusehubIdentity:
75 """Create a MusehubIdentity + MusehubAuthKey pair in the test DB."""
76 identity = db.MusehubIdentity(
77 identity_id=_uid(),
78 handle=handle,
79 identity_type=identity_type,
80 display_name=handle,
81 scope=scope,
82 expires_at=expires_at,
83 )
84 session.add(identity)
85 await session.flush()
86
87 key_row = MusehubAuthKey(
88 key_id=_uid(),
89 identity_id=identity.identity_id,
90 algorithm="ed25519",
91 public_key_b64=b64url_encode(pub),
92 fingerprint=key_fingerprint(pub),
93 label="test-key",
94 )
95 session.add(key_row)
96 await session.commit()
97 await session.refresh(identity)
98 return identity
99
100
101 # ---------------------------------------------------------------------------
102 # 1. Expiry enforcement (E2E via HTTP client)
103 # ---------------------------------------------------------------------------
104
105
106 async def test_expired_agent_returns_401(client: AsyncClient, db_session: AsyncSession) -> None:
107 """An expired identity is rejected with 401 regardless of key validity."""
108 priv, pub = _keypair()
109 handle = "expired-bot-" + _uid()[:8]
110 await _seed_identity(
111 db_session, handle, priv, pub,
112 identity_type="agent",
113 expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
114 )
115 auth = _msign_header(priv, handle, "GET", "/api/repos")
116 resp = await client.get("/api/repos", headers={"Authorization": auth})
117 assert resp.status_code == 401
118 assert "expired" in resp.json().get("detail", "").lower()
119
120
121 async def test_not_yet_expired_agent_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
122 """An agent whose expires_at is in the future passes the auth check."""
123 priv, pub = _keypair()
124 handle = "fresh-bot-" + _uid()[:8]
125 await _seed_identity(
126 db_session, handle, priv, pub,
127 identity_type="agent",
128 scope=["issue:write"],
129 expires_at=datetime.now(timezone.utc) + timedelta(hours=2),
130 )
131 auth = _msign_header(priv, handle, "GET", "/api/repos")
132 resp = await client.get("/api/repos", headers={"Authorization": auth})
133 # Any non-401 means the auth check passed (could be 200 or scope-gated 403)
134 assert resp.status_code != 401
135
136
137 async def test_human_without_expiry_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
138 """Human identities with no expires_at are not rejected."""
139 priv, pub = _keypair()
140 handle = "human-noexp-" + _uid()[:8]
141 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
142 auth = _msign_header(priv, handle, "GET", "/api/repos")
143 resp = await client.get("/api/repos", headers={"Authorization": auth})
144 assert resp.status_code != 401
145
146
147 # ---------------------------------------------------------------------------
148 # 2. Scope propagation (service layer — _verify_msign directly)
149 # ---------------------------------------------------------------------------
150
151
152 async def test_agent_scope_propagated_to_msign_context(db_session: AsyncSession) -> None:
153 """scope list from MusehubIdentity is set on MSignContext after verification."""
154 priv, pub = _keypair()
155 handle = "scoped-agent-" + _uid()[:8]
156 await _seed_identity(
157 db_session, handle, priv, pub,
158 identity_type="agent",
159 scope=["issue:write", "proposal:write"],
160 )
161
162 method = "GET"
163 path = "/test"
164 ts = int(time.time())
165 canonical = build_canonical_message(method, path, ts, b"", host="")
166 sig_bytes = priv.sign(canonical)
167 sig_b64 = b64url_encode(sig_bytes)
168 auth_header = f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
169
170 request = MagicMock()
171 request.headers.get.return_value = auth_header
172 request.method = method
173 request.url.path = path
174 request.url.query = ""
175 request.url.hostname = ""
176 request.url.port = None
177 request.url.scheme = "http"
178 request.body = AsyncMock(return_value=b"")
179
180 ctx = await _verify_msign(request, db_session, required=True)
181 assert ctx is not None
182 assert ctx.scope == ["issue:write", "proposal:write"]
183 assert ctx.is_agent is True
184
185
186 async def test_human_scope_is_none_in_context(db_session: AsyncSession) -> None:
187 """Human identity with no scope column → MSignContext.scope is None."""
188 priv, pub = _keypair()
189 handle = "human-noscope-" + _uid()[:8]
190 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
191
192 method = "GET"
193 path = "/test"
194 ts = int(time.time())
195 canonical = build_canonical_message(method, path, ts, b"", host="")
196 sig_bytes = priv.sign(canonical)
197 sig_b64 = b64url_encode(sig_bytes)
198 auth_header = f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
199
200 request = MagicMock()
201 request.headers.get.return_value = auth_header
202 request.method = method
203 request.url.path = path
204 request.url.query = ""
205 request.url.hostname = ""
206 request.url.port = None
207 request.url.scheme = "http"
208 request.body = AsyncMock(return_value=b"")
209
210 ctx = await _verify_msign(request, db_session, required=True)
211 assert ctx is not None
212 assert ctx.scope is None
213 assert ctx.is_agent is False
214
215
216 # ---------------------------------------------------------------------------
217 # 3. require_scope() unit logic (pure — no DB needed)
218 # ---------------------------------------------------------------------------
219
220
221 async def test_require_scope_human_scope_none_passes() -> None:
222 """require_scope() passes when claims.scope is None (human identity)."""
223 ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False, scope=None)
224 inner_dep = dep_require_scope("issue:write")
225 result = await inner_dep(claims=ctx)
226 assert result is ctx
227
228
229 async def test_require_scope_agent_matching_scope_passes() -> None:
230 """require_scope() passes when agent scope contains the required value."""
231 ctx = MSignContext(
232 handle="bot", identity_id="x", is_agent=True, is_admin=False,
233 scope=["issue:write", "proposal:write"],
234 )
235 inner_dep = dep_require_scope("issue:write")
236 result = await inner_dep(claims=ctx)
237 assert result is ctx
238
239
240 async def test_require_scope_agent_missing_scope_raises_403() -> None:
241 """require_scope() raises HTTP 403 when agent scope lacks the required value."""
242 ctx = MSignContext(
243 handle="bot", identity_id="x", is_agent=True, is_admin=False,
244 scope=["label:read"],
245 )
246 inner_dep = dep_require_scope("issue:write")
247 with pytest.raises(HTTPException) as exc_info:
248 await inner_dep(claims=ctx)
249 assert exc_info.value.status_code == 403
250 assert "issue:write" in exc_info.value.detail
251
252
253 async def test_require_scope_empty_scope_list_blocks_everything() -> None:
254 """Agent with scope=[] (empty list) is blocked from any scoped operation."""
255 ctx = MSignContext(
256 handle="bot", identity_id="x", is_agent=True, is_admin=False,
257 scope=[],
258 )
259 for required in ("issue:write", "proposal:write", "label:read", "release:write"):
260 inner_dep = dep_require_scope(required)
261 with pytest.raises(HTTPException) as exc_info:
262 await inner_dep(claims=ctx)
263 assert exc_info.value.status_code == 403
264
265
266 async def test_require_scope_returns_callable() -> None:
267 """require_scope() factory returns an awaitable callable."""
268 import inspect
269 dep = require_scope("issue:write")
270 assert inspect.iscoroutinefunction(dep)
271
272
273 # ---------------------------------------------------------------------------
274 # 4. Scope enforcement via HTTP (route-level)
275 # ---------------------------------------------------------------------------
276
277
278 async def test_agent_missing_issue_write_scope_gets_403_on_issue_create(
279 client: AsyncClient, db_session: AsyncSession
280 ) -> None:
281 """Agent without issue:write scope receives 403 when creating an issue."""
282 import json as _json
283 from tests.factories import create_repo
284
285 priv, pub = _keypair()
286 handle = "bot-no-issue-" + _uid()[:8]
287 await _seed_identity(
288 db_session, handle, priv, pub,
289 identity_type="agent",
290 scope=["label:read"], # intentionally missing issue:write
291 )
292 repo = await create_repo(db_session, owner=handle, visibility="public")
293
294 path = f"/api/repos/{repo.repo_id}/issues"
295 request_body = _json.dumps({"title": "Forbidden", "body": "body"}).encode()
296 auth = _msign_header(priv, handle, "POST", path, body=request_body)
297 resp = await client.post(
298 path,
299 content=request_body,
300 headers={"Authorization": auth, "Content-Type": "application/json"},
301 )
302 assert resp.status_code == 403
303 assert "issue:write" in resp.json().get("detail", "")
304
305
306 async def test_agent_with_issue_write_scope_passes_scope_check(
307 client: AsyncClient, db_session: AsyncSession
308 ) -> None:
309 """Agent with issue:write scope is not rejected by scope check on issue creation."""
310 import json as _json
311 from tests.factories import create_repo
312
313 priv, pub = _keypair()
314 handle = "bot-issue-w-" + _uid()[:8]
315 await _seed_identity(
316 db_session, handle, priv, pub,
317 identity_type="agent",
318 scope=["issue:write", "issue:read"],
319 )
320 repo = await create_repo(db_session, owner=handle, visibility="public")
321
322 path = f"/api/repos/{repo.repo_id}/issues"
323 request_body = _json.dumps({"title": "Agent issue", "body": "agent body"}).encode()
324 auth = _msign_header(priv, handle, "POST", path, body=request_body)
325 resp = await client.post(
326 path,
327 content=request_body,
328 headers={"Authorization": auth, "Content-Type": "application/json"},
329 )
330 # Scope check passes → 201 created (or a validation error, but NOT 403)
331 assert resp.status_code != 403
332
333
334 async def test_agent_missing_proposal_write_scope_gets_403(
335 client: AsyncClient, db_session: AsyncSession
336 ) -> None:
337 """Agent without proposal:write scope receives 403 when creating a proposal."""
338 import json as _json
339 from tests.factories import create_repo
340
341 priv, pub = _keypair()
342 handle = "bot-no-prop-" + _uid()[:8]
343 await _seed_identity(
344 db_session, handle, priv, pub,
345 identity_type="agent",
346 scope=["issue:write", "issue:read"], # no proposal:write
347 )
348 repo = await create_repo(db_session, owner=handle, visibility="public")
349
350 path = f"/api/repos/{repo.repo_id}/proposals"
351 request_body = _json.dumps({"title": "Test", "from_branch": "feat/x", "to_branch": "dev"}).encode()
352 auth = _msign_header(priv, handle, "POST", path, body=request_body)
353 resp = await client.post(
354 path,
355 content=request_body,
356 headers={"Authorization": auth, "Content-Type": "application/json"},
357 )
358 assert resp.status_code == 403
359 assert "proposal:write" in resp.json().get("detail", "")
360
361
362 async def test_human_passes_scope_check_on_issue_create(
363 client: AsyncClient, db_session: AsyncSession
364 ) -> None:
365 """Human identity (scope=None) bypasses scope enforcement and can create issues."""
366 import json as _json
367 from tests.factories import create_repo
368
369 priv, pub = _keypair()
370 handle = "human-full-" + _uid()[:8]
371 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
372 repo = await create_repo(db_session, owner=handle, visibility="public")
373
374 path = f"/api/repos/{repo.repo_id}/issues"
375 request_body = _json.dumps({"title": "Human issue", "body": "body"}).encode()
376 auth = _msign_header(priv, handle, "POST", path, body=request_body)
377 resp = await client.post(
378 path,
379 content=request_body,
380 headers={"Authorization": auth, "Content-Type": "application/json"},
381 )
382 # Human should not be blocked by scope check
383 assert resp.status_code != 403
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago