1
0

TokenIssuerBindingTest.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  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 F16: admin tokens carry the issuing user's id; the auth
  9. * middleware refuses tokens whose issuer is later disabled or demoted
  10. * below the bound role.
  11. *
  12. * Reporter / consumer / service tokens stay user-less (they are device
  13. * credentials, not delegated user privilege) — covered here only to assert
  14. * the binding is admin-only.
  15. *
  16. * Tokens minted before this migration (and via `bin/console
  17. * tokens:create`) carry `user_id = NULL`; they are grandfathered, exercise
  18. * `testLegacyUnboundAdminTokenStillAuthenticates` below.
  19. */
  20. final class TokenIssuerBindingTest extends AppTestCase
  21. {
  22. public function testAdminTokenCreatedViaApiIsBoundToActingAdmin(): void
  23. {
  24. $service = $this->createToken(TokenKind::Service);
  25. $issuer = $this->createUser(Role::Admin);
  26. $resp = $this->request(
  27. 'POST',
  28. '/api/v1/admin/tokens',
  29. [
  30. 'Authorization' => 'Bearer ' . $service,
  31. 'X-Acting-User-Id' => (string) $issuer,
  32. 'Content-Type' => 'application/json',
  33. ],
  34. json_encode(['kind' => 'admin', 'role' => 'operator']) ?: null,
  35. );
  36. self::assertSame(201, $resp->getStatusCode());
  37. $body = $this->decode($resp);
  38. self::assertSame($issuer, (int) $body['user_id']);
  39. // The audit row carries the issuer id too — so a SOC reader can
  40. // attribute the mint without joining api_tokens.
  41. $auditRow = $this->db->fetchAssociative(
  42. "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
  43. );
  44. self::assertIsArray($auditRow);
  45. $details = json_decode((string) $auditRow['details_json'], true);
  46. self::assertIsArray($details);
  47. self::assertSame($issuer, (int) $details['user_id']);
  48. }
  49. public function testReporterTokenCreatedViaApiIsNotBoundToUser(): void
  50. {
  51. $service = $this->createToken(TokenKind::Service);
  52. $issuer = $this->createUser(Role::Admin);
  53. $reporterId = $this->createReporter('rep-bind-check');
  54. $resp = $this->request(
  55. 'POST',
  56. '/api/v1/admin/tokens',
  57. [
  58. 'Authorization' => 'Bearer ' . $service,
  59. 'X-Acting-User-Id' => (string) $issuer,
  60. 'Content-Type' => 'application/json',
  61. ],
  62. json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
  63. );
  64. self::assertSame(201, $resp->getStatusCode());
  65. $body = $this->decode($resp);
  66. self::assertArrayHasKey('user_id', $body);
  67. self::assertNull($body['user_id']);
  68. }
  69. public function testListSurfacesIssuerLabel(): void
  70. {
  71. $service = $this->createToken(TokenKind::Service);
  72. $issuer = $this->createUser(Role::Admin);
  73. $this->db->update('users', ['display_name' => 'Carol Admin'], ['id' => $issuer]);
  74. // Mint via the API so the binding actually fires.
  75. $this->request(
  76. 'POST',
  77. '/api/v1/admin/tokens',
  78. [
  79. 'Authorization' => 'Bearer ' . $service,
  80. 'X-Acting-User-Id' => (string) $issuer,
  81. 'Content-Type' => 'application/json',
  82. ],
  83. json_encode(['kind' => 'admin', 'role' => 'viewer']) ?: null,
  84. );
  85. $list = $this->request('GET', '/api/v1/admin/tokens', [
  86. 'Authorization' => 'Bearer ' . $service,
  87. 'X-Acting-User-Id' => (string) $issuer,
  88. ]);
  89. self::assertSame(200, $list->getStatusCode());
  90. $rows = $this->decode($list)['data'];
  91. $minted = null;
  92. foreach ($rows as $row) {
  93. if (($row['kind'] ?? null) === 'admin' && ($row['role'] ?? null) === 'viewer') {
  94. $minted = $row;
  95. break;
  96. }
  97. }
  98. self::assertIsArray($minted, 'minted admin row not present in list');
  99. self::assertSame($issuer, (int) $minted['user_id']);
  100. self::assertSame('Carol Admin', $minted['user_label']);
  101. }
  102. public function testBoundAdminTokenAuthenticatesWhileIssuerActive(): void
  103. {
  104. $issuer = $this->createUser(Role::Admin);
  105. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
  106. // Pick any admin-only endpoint that returns 200 for an Admin role.
  107. $resp = $this->request('GET', '/api/v1/admin/tokens', [
  108. 'Authorization' => 'Bearer ' . $token,
  109. ]);
  110. self::assertSame(200, $resp->getStatusCode());
  111. }
  112. public function testBoundAdminTokenIsRejectedAfterIssuerDisabled(): void
  113. {
  114. $issuer = $this->createUser(Role::Admin);
  115. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
  116. $this->db->update(
  117. 'users',
  118. ['disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s')],
  119. ['id' => $issuer],
  120. );
  121. $resp = $this->request('GET', '/api/v1/admin/tokens', [
  122. 'Authorization' => 'Bearer ' . $token,
  123. ]);
  124. self::assertSame(401, $resp->getStatusCode());
  125. self::assertSame('unauthorized', $this->decode($resp)['error']);
  126. }
  127. public function testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole(): void
  128. {
  129. $issuer = $this->createUser(Role::Admin);
  130. // Token grants Admin to whoever holds it.
  131. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
  132. // Issuer demoted to Viewer — the admin-grant token must stop working.
  133. $this->db->update('users', ['role' => Role::Viewer->value], ['id' => $issuer]);
  134. $resp = $this->request('GET', '/api/v1/admin/tokens', [
  135. 'Authorization' => 'Bearer ' . $token,
  136. ]);
  137. self::assertSame(401, $resp->getStatusCode());
  138. }
  139. public function testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole(): void
  140. {
  141. $issuer = $this->createUser(Role::Admin);
  142. // Token grants only Viewer; issuer remains Admin (>= Viewer) → token works.
  143. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer, userId: $issuer);
  144. // Endpoint requires Viewer — admin/jobs/status is Viewer-tier.
  145. $resp = $this->request('GET', '/api/v1/admin/jobs/status', [
  146. 'Authorization' => 'Bearer ' . $token,
  147. ]);
  148. self::assertSame(200, $resp->getStatusCode());
  149. }
  150. public function testBoundAdminTokenIsRejectedIfIssuerRowIsGone(): void
  151. {
  152. $issuer = $this->createUser(Role::Admin);
  153. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
  154. // Simulate a hard delete: the issuer row is removed but the token
  155. // row points at the (now-orphaned) user_id. On MySQL the FK CASCADE
  156. // would kill the token row first; on SQLite the application-layer
  157. // user lookup returns null and the middleware refuses the token.
  158. $this->db->delete('users', ['id' => $issuer]);
  159. $resp = $this->request('GET', '/api/v1/admin/tokens', [
  160. 'Authorization' => 'Bearer ' . $token,
  161. ]);
  162. self::assertSame(401, $resp->getStatusCode());
  163. }
  164. public function testLegacyUnboundAdminTokenStillAuthenticates(): void
  165. {
  166. // Admin token created without any user_id — legacy / console-issued.
  167. // F16 grandfathers these so existing deployments keep working;
  168. // operators rotate them after they redeploy.
  169. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: null);
  170. $resp = $this->request('GET', '/api/v1/admin/tokens', [
  171. 'Authorization' => 'Bearer ' . $token,
  172. ]);
  173. self::assertSame(200, $resp->getStatusCode());
  174. }
  175. }