DisabledUserTest.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * SEC_REVIEW F11: a disabled `users` row is unimpersonatable, refuses
  9. * OIDC re-login (no role recompute, no audit drift), and refuses local
  10. * sign-in. The admin user-CRUD endpoint is the only path that toggles
  11. * `disabled_at` — it audits both directions and refuses self-disable
  12. * and local-admin-disable.
  13. */
  14. final class DisabledUserTest extends AppTestCase
  15. {
  16. public function testServiceTokenImpersonatingDisabledUserReturns403(): void
  17. {
  18. $token = $this->createToken(TokenKind::Service);
  19. $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-disabled', disabled: true);
  20. $response = $this->request('GET', '/api/v1/admin/me', [
  21. 'Authorization' => 'Bearer ' . $token,
  22. 'X-Acting-User-Id' => (string) $userId,
  23. ]);
  24. self::assertSame(403, $response->getStatusCode());
  25. self::assertSame('user_disabled', $this->decode($response)['error']);
  26. }
  27. public function testEnabledUserOfSameKindStillWorksWhenAnotherIsDisabled(): void
  28. {
  29. // Sanity: disabling user A must not break impersonation for user B.
  30. $token = $this->createToken(TokenKind::Service);
  31. $disabledId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-A', disabled: true);
  32. $activeId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-B', disabled: false);
  33. $disabled = $this->request('GET', '/api/v1/admin/me', [
  34. 'Authorization' => 'Bearer ' . $token,
  35. 'X-Acting-User-Id' => (string) $disabledId,
  36. ]);
  37. self::assertSame(403, $disabled->getStatusCode());
  38. $active = $this->request('GET', '/api/v1/admin/me', [
  39. 'Authorization' => 'Bearer ' . $token,
  40. 'X-Acting-User-Id' => (string) $activeId,
  41. ]);
  42. self::assertSame(200, $active->getStatusCode());
  43. }
  44. public function testUpsertOidcRefusesDisabledUserAndDoesNotRecomputeRole(): void
  45. {
  46. // A returning OIDC subject whose row was disabled must 403,
  47. // and the role on the (still disabled) row must remain
  48. // whatever it was — we must NOT have applied the new groups.
  49. $token = $this->createToken(TokenKind::Service);
  50. $this->db->insert('users', [
  51. 'subject' => 'churn-disabled',
  52. 'email' => 'old@example.com',
  53. 'display_name' => 'Churned',
  54. 'role' => Role::Viewer->value,
  55. 'is_local' => 0,
  56. 'disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
  57. ]);
  58. $this->db->insert('oidc_role_mappings', [
  59. 'group_id' => 'admin-grp',
  60. 'role' => Role::Admin->value,
  61. ]);
  62. $response = $this->request(
  63. 'POST',
  64. '/api/v1/auth/users/upsert-oidc',
  65. [
  66. 'Authorization' => 'Bearer ' . $token,
  67. 'Content-Type' => 'application/json',
  68. ],
  69. (string) json_encode([
  70. 'subject' => 'churn-disabled',
  71. 'email' => 'new@example.com',
  72. 'display_name' => 'Renamed',
  73. 'groups' => ['admin-grp'],
  74. ])
  75. );
  76. self::assertSame(403, $response->getStatusCode());
  77. self::assertSame('user_disabled', $this->decode($response)['error']);
  78. // Role must not have been recomputed.
  79. $row = $this->db->fetchAssociative(
  80. "SELECT role, email, display_name FROM users WHERE subject = 'churn-disabled'"
  81. );
  82. self::assertIsArray($row);
  83. self::assertSame('viewer', $row['role'], 'disabled user role must not be recomputed from new groups');
  84. self::assertSame('old@example.com', $row['email']);
  85. self::assertSame('Churned', $row['display_name']);
  86. // No user.role_changed audit row from this denied attempt.
  87. $count = (int) $this->db->fetchOne(
  88. "SELECT COUNT(*) FROM audit_log WHERE action = 'user.role_changed'"
  89. );
  90. self::assertSame(0, $count, 'denied login must not emit role_changed audit');
  91. }
  92. public function testUpsertLocalRefusesWhenLocalAdminDisabled(): void
  93. {
  94. $token = $this->createToken(TokenKind::Service);
  95. // Seed the local admin row pre-disabled.
  96. $this->createUser(Role::Admin, isLocal: true, disabled: true);
  97. $response = $this->request(
  98. 'POST',
  99. '/api/v1/auth/users/upsert-local',
  100. [
  101. 'Authorization' => 'Bearer ' . $token,
  102. 'Content-Type' => 'application/json',
  103. ],
  104. (string) json_encode(['username' => 'admin'])
  105. );
  106. self::assertSame(403, $response->getStatusCode());
  107. self::assertSame('user_disabled', $this->decode($response)['error']);
  108. }
  109. public function testActorViaIsLocalForServiceImpersonationOfLocalAdmin(): void
  110. {
  111. // Layer B2: any audit emitted while impersonating a local user
  112. // carries actor_via='local'; an OIDC user yields 'oidc'.
  113. $token = $this->createToken(TokenKind::Service);
  114. $localAdmin = $this->createUser(Role::Admin, isLocal: true);
  115. $oidcAdmin = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-via-oidc');
  116. // Drive an audit-emitting write under the local admin.
  117. $this->request(
  118. 'POST',
  119. '/api/v1/admin/manual-blocks',
  120. [
  121. 'Authorization' => 'Bearer ' . $token,
  122. 'X-Acting-User-Id' => (string) $localAdmin,
  123. 'Content-Type' => 'application/json',
  124. ],
  125. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.1', 'reason' => 'via=local'])
  126. );
  127. $row = $this->db->fetchAssociative(
  128. "SELECT actor_kind, actor_id, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
  129. );
  130. self::assertIsArray($row);
  131. self::assertSame('user', $row['actor_kind']);
  132. self::assertSame((string) $localAdmin, $row['actor_id']);
  133. self::assertSame('local', $row['actor_via']);
  134. // Same write under the OIDC admin → actor_via='oidc'.
  135. $this->request(
  136. 'POST',
  137. '/api/v1/admin/manual-blocks',
  138. [
  139. 'Authorization' => 'Bearer ' . $token,
  140. 'X-Acting-User-Id' => (string) $oidcAdmin,
  141. 'Content-Type' => 'application/json',
  142. ],
  143. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.2', 'reason' => 'via=oidc'])
  144. );
  145. $row = $this->db->fetchAssociative(
  146. "SELECT actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
  147. );
  148. self::assertIsArray($row);
  149. self::assertSame('oidc', $row['actor_via']);
  150. }
  151. public function testActorViaIsAdminTokenForBareAdminToken(): void
  152. {
  153. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  154. $this->request(
  155. 'POST',
  156. '/api/v1/admin/manual-blocks',
  157. [
  158. 'Authorization' => 'Bearer ' . $token,
  159. 'Content-Type' => 'application/json',
  160. ],
  161. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.3', 'reason' => 'via=admin-token'])
  162. );
  163. $row = $this->db->fetchAssociative(
  164. "SELECT actor_kind, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
  165. );
  166. self::assertIsArray($row);
  167. self::assertSame('admin-token', $row['actor_kind']);
  168. self::assertSame('admin-token', $row['actor_via']);
  169. }
  170. public function testAdminUsersListIncludesDisabledFlag(): void
  171. {
  172. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  173. $active = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-active');
  174. $disabled = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-off', disabled: true);
  175. $response = $this->request('GET', '/api/v1/admin/users', [
  176. 'Authorization' => 'Bearer ' . $token,
  177. ]);
  178. self::assertSame(200, $response->getStatusCode());
  179. $body = $this->decode($response);
  180. $byId = [];
  181. foreach ($body['items'] as $item) {
  182. $byId[(int) $item['id']] = $item;
  183. }
  184. self::assertArrayHasKey($active, $byId);
  185. self::assertArrayHasKey($disabled, $byId);
  186. self::assertFalse($byId[$active]['disabled']);
  187. self::assertNull($byId[$active]['disabled_at']);
  188. self::assertTrue($byId[$disabled]['disabled']);
  189. self::assertNotNull($byId[$disabled]['disabled_at']);
  190. }
  191. public function testAdminPatchDisablesUserAndEmitsAudit(): void
  192. {
  193. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  194. $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'patch-target');
  195. $response = $this->request(
  196. 'PATCH',
  197. '/api/v1/admin/users/' . $target,
  198. [
  199. 'Authorization' => 'Bearer ' . $token,
  200. 'Content-Type' => 'application/json',
  201. ],
  202. (string) json_encode(['disabled' => true])
  203. );
  204. self::assertSame(200, $response->getStatusCode());
  205. $body = $this->decode($response);
  206. self::assertTrue($body['disabled']);
  207. self::assertNotNull($body['disabled_at']);
  208. $row = $this->db->fetchAssociative(
  209. "SELECT action, target_id, actor_kind FROM audit_log WHERE action = 'user.disabled' ORDER BY id DESC LIMIT 1"
  210. );
  211. self::assertIsArray($row);
  212. self::assertSame((string) $target, $row['target_id']);
  213. // Reverse — flipping back emits user.enabled.
  214. $back = $this->request(
  215. 'PATCH',
  216. '/api/v1/admin/users/' . $target,
  217. [
  218. 'Authorization' => 'Bearer ' . $token,
  219. 'Content-Type' => 'application/json',
  220. ],
  221. (string) json_encode(['disabled' => false])
  222. );
  223. self::assertSame(200, $back->getStatusCode());
  224. self::assertFalse($this->decode($back)['disabled']);
  225. $enabledRow = $this->db->fetchAssociative(
  226. "SELECT action FROM audit_log WHERE action = 'user.enabled' ORDER BY id DESC LIMIT 1"
  227. );
  228. self::assertIsArray($enabledRow);
  229. }
  230. public function testAdminPatchRefusesSelfDisable(): void
  231. {
  232. $token = $this->createToken(TokenKind::Service);
  233. $self = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-self');
  234. $response = $this->request(
  235. 'PATCH',
  236. '/api/v1/admin/users/' . $self,
  237. [
  238. 'Authorization' => 'Bearer ' . $token,
  239. 'X-Acting-User-Id' => (string) $self,
  240. 'Content-Type' => 'application/json',
  241. ],
  242. (string) json_encode(['disabled' => true])
  243. );
  244. self::assertSame(409, $response->getStatusCode());
  245. self::assertSame('cannot_disable_self', $this->decode($response)['error']);
  246. $row = $this->db->fetchAssociative(
  247. 'SELECT disabled_at FROM users WHERE id = :id',
  248. ['id' => $self]
  249. );
  250. self::assertIsArray($row);
  251. self::assertNull($row['disabled_at']);
  252. }
  253. public function testAdminPatchRefusesDisablingLocalAdmin(): void
  254. {
  255. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  256. $local = $this->createUser(Role::Admin, isLocal: true);
  257. $response = $this->request(
  258. 'PATCH',
  259. '/api/v1/admin/users/' . $local,
  260. [
  261. 'Authorization' => 'Bearer ' . $token,
  262. 'Content-Type' => 'application/json',
  263. ],
  264. (string) json_encode(['disabled' => true])
  265. );
  266. self::assertSame(409, $response->getStatusCode());
  267. self::assertSame('cannot_disable_local_admin', $this->decode($response)['error']);
  268. }
  269. public function testAdminPatchIsIdempotentWithNoAuditOnNoOp(): void
  270. {
  271. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  272. $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-noop');
  273. // Already enabled → patch enabled=false (no, we want already-active, set enabled): set disabled=false (no-op).
  274. $beforeAuditCount = (int) $this->db->fetchOne(
  275. "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
  276. );
  277. $response = $this->request(
  278. 'PATCH',
  279. '/api/v1/admin/users/' . $target,
  280. [
  281. 'Authorization' => 'Bearer ' . $token,
  282. 'Content-Type' => 'application/json',
  283. ],
  284. (string) json_encode(['disabled' => false])
  285. );
  286. self::assertSame(200, $response->getStatusCode());
  287. $afterAuditCount = (int) $this->db->fetchOne(
  288. "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
  289. );
  290. self::assertSame($beforeAuditCount, $afterAuditCount, 'no-op patch must not emit audit');
  291. }
  292. public function testAdminUsersRequiresAdminRole(): void
  293. {
  294. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  295. $response = $this->request('GET', '/api/v1/admin/users', [
  296. 'Authorization' => 'Bearer ' . $token,
  297. ]);
  298. self::assertSame(403, $response->getStatusCode());
  299. }
  300. public function testAuditLogActorViaFilter(): void
  301. {
  302. // Filter on actor_via='local' returns only impersonated-local rows.
  303. $token = $this->createToken(TokenKind::Service);
  304. $local = $this->createUser(Role::Admin, isLocal: true);
  305. $oidc = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-filter');
  306. $localToken = $this->createToken(TokenKind::Admin, role: Role::Admin);
  307. // Three writes: two impersonated (one local, one oidc) + one admin token.
  308. $this->request('POST', '/api/v1/admin/manual-blocks', [
  309. 'Authorization' => 'Bearer ' . $token,
  310. 'X-Acting-User-Id' => (string) $local,
  311. 'Content-Type' => 'application/json',
  312. ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.1', 'reason' => 'l']));
  313. $this->request('POST', '/api/v1/admin/manual-blocks', [
  314. 'Authorization' => 'Bearer ' . $token,
  315. 'X-Acting-User-Id' => (string) $oidc,
  316. 'Content-Type' => 'application/json',
  317. ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.2', 'reason' => 'o']));
  318. $this->request('POST', '/api/v1/admin/manual-blocks', [
  319. 'Authorization' => 'Bearer ' . $localToken,
  320. 'Content-Type' => 'application/json',
  321. ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.3', 'reason' => 'a']));
  322. // Admin-list filtered to actor_via=local.
  323. $resp = $this->request(
  324. 'GET',
  325. '/api/v1/admin/audit-log?actor_via=local',
  326. ['Authorization' => 'Bearer ' . $localToken],
  327. );
  328. self::assertSame(200, $resp->getStatusCode());
  329. $body = $this->decode($resp);
  330. $vias = array_map(static fn (array $row) => $row['actor_via'], $body['items']);
  331. self::assertNotEmpty($vias);
  332. foreach ($vias as $via) {
  333. self::assertSame('local', $via);
  334. }
  335. }
  336. }