gabriel / musehub public
test_identity_admin_separation.py python
297 lines 10.9 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
1 """TDD — identity_type and admin role must be separate concerns.
2
3 Design invariants
4 -----------------
5 identity_type ∈ {"human", "agent", "org"}
6 Describes what kind of entity an identity IS.
7 Set at registration and immutable thereafter.
8 "org" identities are created via POST /api/orgs, not self-registration.
9
10 is_admin (bool column on musehub_identities)
11 A MuseHub-level privilege flag — describes what a human CAN DO on the hub.
12 Defaults False. Set only by an out-of-band operator action (migration or
13 a future admin-grant endpoint), never by self-registration.
14
15 These two concepts must never be conflated. Specifically:
16 - identity_type="admin" must not be a valid value.
17 - Self-registration (POST /api/auth/verify) must reject identity_type="admin".
18 - MSignContext.is_admin must derive from the is_admin column, not identity_type.
19 - Admin-gated endpoints must check the column, not the type string.
20
21 Tests
22 -----
23 R1 Valid self-registration identity_types — "human" and "agent" are accepted.
24 R2 "admin" identity_type rejected — POST /api/auth/verify with identity_type="admin"
25 must return a validation error.
26 R3 is_admin column exists on MusehubIdentity — ORM model has the field.
27 R4 MSignContext.is_admin derives from is_admin column — a human identity with
28 is_admin=True produces is_admin=True in context, not because of identity_type.
29 R5 identity_type="admin" does NOT grant is_admin — old conflation no longer works.
30 R6 Admin domain endpoints accept human with is_admin=True.
31 R7 Admin domain endpoints reject human with is_admin=False.
32 """
33 from __future__ import annotations
34
35 import pytest
36 from httpx import AsyncClient
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from musehub.auth.request_signing import MSignContext
40 from musehub.db.musehub_identity_models import MusehubIdentity
41 from musehub.db.musehub_domain_models import MusehubDomain
42 from musehub.main import app
43 from musehub.auth.request_signing import require_signed_request
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50 def _make_identity(
51 handle: str,
52 identity_type: str = "human",
53 is_admin: bool = False,
54 ) -> MusehubIdentity:
55 from muse.core.types import long_id
56 identity_id = long_id(handle.ljust(64, "0")[:64])
57 return MusehubIdentity(
58 identity_id=identity_id,
59 handle=handle,
60 identity_type=identity_type,
61 is_admin=is_admin,
62 )
63
64
65 # ---------------------------------------------------------------------------
66 # R3 — is_admin column exists on MusehubIdentity
67 # ---------------------------------------------------------------------------
68
69 def test_r3_is_admin_column_exists_on_orm_model() -> None:
70 """MusehubIdentity must have an is_admin boolean field."""
71 identity = MusehubIdentity(
72 identity_id="sha256:" + "a" * 64,
73 handle="testuser",
74 identity_type="human",
75 is_admin=False,
76 )
77 assert hasattr(identity, "is_admin")
78 assert identity.is_admin is False
79
80
81 def test_r3_is_admin_defaults_false() -> None:
82 """is_admin must default to False so existing rows are not accidentally elevated."""
83 identity = MusehubIdentity(
84 identity_id="sha256:" + "b" * 64,
85 handle="testuser2",
86 identity_type="human",
87 )
88 assert identity.is_admin is False
89
90
91 # ---------------------------------------------------------------------------
92 # R4 — MSignContext.is_admin reads the column, not identity_type
93 # ---------------------------------------------------------------------------
94
95 def test_r4_msign_context_is_admin_from_column() -> None:
96 """A human identity with is_admin=True must produce is_admin=True in MSignContext.
97
98 The context must be built using identity.is_admin (column), not
99 identity_type == 'admin' (the old broken derivation).
100 """
101 identity = MusehubIdentity(
102 identity_id="sha256:" + "c" * 64,
103 handle="admin-human",
104 identity_type="human",
105 is_admin=True,
106 )
107 # Correct derivation: read the column, not the type string
108 ctx = MSignContext(
109 handle=identity.handle,
110 identity_id=identity.identity_id,
111 is_agent=(identity.identity_type == "agent"),
112 is_admin=identity.is_admin,
113 scope=identity.scope,
114 )
115 assert ctx.is_admin is True
116 assert ctx.is_agent is False
117
118
119 def test_r4_human_identity_type_without_is_admin_flag_is_not_admin() -> None:
120 """A human identity with is_admin=False produces is_admin=False in context."""
121 identity = MusehubIdentity(
122 identity_id="sha256:" + "d" * 64,
123 handle="plain-human",
124 identity_type="human",
125 is_admin=False,
126 )
127 ctx = MSignContext(
128 handle=identity.handle,
129 identity_id=identity.identity_id,
130 is_agent=False,
131 is_admin=identity.is_admin,
132 scope=identity.scope,
133 )
134 assert ctx.is_admin is False
135
136
137 # ---------------------------------------------------------------------------
138 # R5 — identity_type="admin" no longer grants is_admin
139 # ---------------------------------------------------------------------------
140
141 def test_r5_identity_type_admin_string_does_not_grant_is_admin() -> None:
142 """The old conflation identity_type=='admin' must no longer set is_admin=True.
143
144 A stale row with identity_type='admin' and is_admin=False (the column default)
145 must NOT produce is_admin=True in MSignContext.
146 """
147 # Simulate stale data: identity_type is "admin" (bad), is_admin column is False
148 identity = MusehubIdentity(
149 identity_id="sha256:" + "e" * 64,
150 handle="stale-admin",
151 identity_type="human", # after migration, this row gets type="human"
152 is_admin=False,
153 )
154 # Old broken derivation: is_admin=(identity.identity_type == "admin") → would be False here
155 # New correct derivation: is_admin=identity.is_admin → also False
156 # The test confirms the correct derivation is used
157 ctx = MSignContext(
158 handle=identity.handle,
159 identity_id=identity.identity_id,
160 is_agent=False,
161 is_admin=identity.is_admin, # column, not type string
162 scope=None,
163 )
164 assert ctx.is_admin is False
165
166
167 # ---------------------------------------------------------------------------
168 # R1 — Valid self-registration identity_types accepted by VerifyRequest
169 # ---------------------------------------------------------------------------
170
171 @pytest.mark.parametrize("itype", ["human", "agent"])
172 def test_r1_valid_self_registration_identity_types_accepted(itype: str) -> None:
173 """VerifyRequest must accept human and agent — the two self-registration types.
174
175 Note: "org" is created via POST /api/orgs, not via /api/auth/verify.
176 """
177 from musehub.models.musehub_auth import VerifyRequest
178 req = VerifyRequest(
179 challenge_token="a" * 64,
180 public_key_b64="dGVzdA==",
181 signature_b64="dGVzdA==",
182 identity_type=itype,
183 )
184 assert req.identity_type == itype
185
186
187 # ---------------------------------------------------------------------------
188 # R2 — "admin" identity_type rejected by VerifyRequest
189 # ---------------------------------------------------------------------------
190
191 def test_r2_admin_identity_type_rejected_by_verify_request() -> None:
192 """VerifyRequest must reject identity_type='admin' with a validation error.
193
194 Self-registration cannot grant admin privileges. Admin is an operational
195 privilege set via a separate flow, never by the registering user.
196 """
197 from pydantic import ValidationError
198 from musehub.models.musehub_auth import VerifyRequest
199
200 with pytest.raises(ValidationError) as exc_info:
201 VerifyRequest(
202 challenge_token="a" * 64,
203 public_key_b64="dGVzdA==",
204 signature_b64="dGVzdA==",
205 identity_type="admin",
206 )
207 errors = exc_info.value.errors()
208 assert any("identity_type" in str(e) for e in errors), (
209 f"Expected validation error on identity_type, got: {errors}"
210 )
211
212
213 def test_r2_arbitrary_identity_type_rejected() -> None:
214 """VerifyRequest must reject arbitrary type strings like 'superuser'."""
215 from pydantic import ValidationError
216 from musehub.models.musehub_auth import VerifyRequest
217
218 with pytest.raises(ValidationError):
219 VerifyRequest(
220 challenge_token="a" * 64,
221 public_key_b64="dGVzdA==",
222 signature_b64="dGVzdA==",
223 identity_type="superuser",
224 )
225
226
227 # ---------------------------------------------------------------------------
228 # R6 — Human with is_admin=True can call domain verify endpoint
229 # ---------------------------------------------------------------------------
230
231 @pytest.mark.asyncio
232 async def test_r6_human_with_is_admin_true_can_verify_domain(
233 client: AsyncClient,
234 db_session: AsyncSession,
235 ) -> None:
236 """A human identity with is_admin=True can call the domain verify endpoint."""
237 handle = "r6-admin-human"
238 identity = _make_identity(handle, identity_type="human", is_admin=True)
239 db_session.add(identity)
240
241 domain = MusehubDomain(
242 domain_id="sha256:" + "f" * 64,
243 author_slug="r6-target",
244 slug="r6domain",
245 display_name="R6 Domain",
246 is_verified=False,
247 )
248 db_session.add(domain)
249 await db_session.commit()
250
251 admin_ctx = MSignContext(
252 handle=handle,
253 identity_id=identity.identity_id,
254 is_agent=False,
255 is_admin=True,
256 )
257 app.dependency_overrides[require_signed_request] = lambda: admin_ctx
258 try:
259 r = await client.post(
260 "/api/domains/@r6-target/r6domain/verify",
261 headers={"Content-Type": "application/json"},
262 )
263 assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}"
264 finally:
265 app.dependency_overrides.pop(require_signed_request, None)
266
267
268 # ---------------------------------------------------------------------------
269 # R7 — Human with is_admin=False is blocked from domain verify endpoint
270 # ---------------------------------------------------------------------------
271
272 @pytest.mark.asyncio
273 async def test_r7_human_with_is_admin_false_cannot_verify_domain(
274 client: AsyncClient,
275 db_session: AsyncSession,
276 ) -> None:
277 """A human identity with is_admin=False (the default) cannot call admin endpoints."""
278 handle = "r7-nonadmin"
279 identity = _make_identity(handle, identity_type="human", is_admin=False)
280 db_session.add(identity)
281 await db_session.commit()
282
283 non_admin_ctx = MSignContext(
284 handle=handle,
285 identity_id=identity.identity_id,
286 is_agent=False,
287 is_admin=False,
288 )
289 app.dependency_overrides[require_signed_request] = lambda: non_admin_ctx
290 try:
291 r = await client.post(
292 "/api/domains/@r7-target/r7domain/verify",
293 headers={"Content-Type": "application/json"},
294 )
295 assert r.status_code == 403, f"Expected 403, got {r.status_code}: {r.text}"
296 finally:
297 app.dependency_overrides.pop(require_signed_request, None)
File History 14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 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 29 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 33 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 35 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 50 days ago