ServiceTokenBootstrap.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Infrastructure\Auth;
  4. use App\Domain\Audit\AuditAction;
  5. use App\Domain\Audit\AuditContext;
  6. use App\Domain\Audit\AuditEmitter;
  7. use App\Domain\Auth\Token;
  8. use App\Domain\Auth\TokenHasher;
  9. use App\Domain\Auth\TokenKind;
  10. use App\Domain\Time\Clock;
  11. use Doctrine\DBAL\Connection;
  12. use Psr\Log\LoggerInterface;
  13. /**
  14. * Ensures the api_tokens table contains a row matching the configured
  15. * UI_SERVICE_TOKEN. Idempotent — safe to call on every container start.
  16. *
  17. * - empty env → log a warning and skip (early-bring-up scenario).
  18. * - hash matches an existing service-kind row → no-op.
  19. * - hash absent → insert.
  20. * - hash absent but a *different* service-kind row exists (rotation):
  21. * atomically revoke every previously-valid service-kind row and insert
  22. * the new one. Emits `token.revoked` (per old) and `token.created` (new)
  23. * audit rows attributed to `system` (SEC_REVIEW F13). The transaction
  24. * keeps "no service token exists" out of any observable state — if the
  25. * insert fails, the revokes roll back too, leaving the prior token
  26. * valid.
  27. */
  28. final class ServiceTokenBootstrap
  29. {
  30. public function __construct(
  31. private readonly TokenRepository $tokens,
  32. private readonly TokenHasher $hasher,
  33. private readonly LoggerInterface $logger,
  34. private readonly Connection $connection,
  35. private readonly AuditEmitter $audit,
  36. private readonly Clock $clock,
  37. ) {
  38. }
  39. public function bootstrap(string $rawToken): void
  40. {
  41. if ($rawToken === '') {
  42. $this->logger->warning('UI_SERVICE_TOKEN is empty; skipping service-token bootstrap');
  43. return;
  44. }
  45. $parsed = Token::parse($rawToken);
  46. if ($parsed === null || $parsed->kind !== TokenKind::Service) {
  47. $this->logger->warning('UI_SERVICE_TOKEN is not a valid `irdb_svc_…` token; skipping');
  48. return;
  49. }
  50. $hash = $this->hasher->hash($rawToken);
  51. $existing = $this->tokens->findByHashIncludingInvalid($hash);
  52. if ($existing !== null) {
  53. if ($existing->kind === TokenKind::Service) {
  54. if ($existing->revokedAt !== null) {
  55. // Operator explicitly revoked this hash, then put it
  56. // back in env. Refuse to silently un-revoke; force the
  57. // operator to issue a fresh value.
  58. $this->logger->error(
  59. 'UI_SERVICE_TOKEN matches a previously-revoked row; refusing to re-enable. '
  60. . 'Issue a fresh token value.'
  61. );
  62. return;
  63. }
  64. $this->logger->info('UI_SERVICE_TOKEN already provisioned; no-op');
  65. return;
  66. }
  67. // A non-service token with the same hash should be impossible
  68. // (160 bits of entropy). If it ever happens, refuse to clobber.
  69. $this->logger->error('UI_SERVICE_TOKEN hash collides with non-service row; refusing to insert');
  70. return;
  71. }
  72. // No existing row by exact hash. SEC_REVIEW F13: if there are any
  73. // currently-valid service-kind rows, we are in a rotation —
  74. // revoke them all atomically with the new insert so the previously
  75. // valid hash cannot authenticate after this bootstrap returns.
  76. $previouslyActive = $this->tokens->findActiveServiceTokens();
  77. $newPrefix = $parsed->prefix();
  78. $auditCtx = AuditContext::system();
  79. $now = $this->clock->now();
  80. $this->connection->transactional(function () use ($previouslyActive, $hash, $newPrefix, $auditCtx, $now): void {
  81. foreach ($previouslyActive as $old) {
  82. if ($old->id === null) {
  83. // Repository hydrates persisted rows; id is always set.
  84. throw new \LogicException('persisted TokenRecord must have id');
  85. }
  86. $this->tokens->revoke($old->id, $now);
  87. $this->audit->emitOrThrow(
  88. AuditAction::TOKEN_REVOKED,
  89. 'token',
  90. $old->id,
  91. [
  92. 'kind' => $old->kind->value,
  93. 'prefix' => $old->prefix,
  94. 'reason' => 'rotated_by_bootstrap',
  95. ],
  96. $auditCtx,
  97. self::tokenLabel($old->kind->value, $old->prefix),
  98. );
  99. }
  100. $this->tokens->create(new TokenRecord(
  101. id: null,
  102. kind: TokenKind::Service,
  103. hash: $hash,
  104. prefix: $newPrefix,
  105. reporterId: null,
  106. consumerId: null,
  107. role: null,
  108. expiresAt: null,
  109. revokedAt: null,
  110. lastUsedAt: null,
  111. ));
  112. $created = $this->tokens->findByHashIncludingInvalid($hash);
  113. if ($created === null) {
  114. throw new \RuntimeException('service token not retrievable after insert');
  115. }
  116. $this->audit->emitOrThrow(
  117. AuditAction::TOKEN_CREATED,
  118. 'token',
  119. $created->id,
  120. [
  121. 'kind' => $created->kind->value,
  122. 'prefix' => $created->prefix,
  123. 'source' => 'bootstrap',
  124. 'rotated_from' => array_map(static fn (TokenRecord $r): string => $r->prefix, $previouslyActive),
  125. ],
  126. $auditCtx,
  127. self::tokenLabel($created->kind->value, $created->prefix),
  128. );
  129. });
  130. if ($previouslyActive !== []) {
  131. $this->logger->warning(
  132. 'UI_SERVICE_TOKEN rotated; revoked previously-valid service token(s) and inserted new one',
  133. [
  134. 'revoked_count' => count($previouslyActive),
  135. 'revoked_prefixes' => array_map(
  136. static fn (TokenRecord $r): string => $r->prefix,
  137. $previouslyActive,
  138. ),
  139. 'new_prefix' => $newPrefix,
  140. ],
  141. );
  142. return;
  143. }
  144. $this->logger->info('UI_SERVICE_TOKEN provisioned');
  145. }
  146. private static function tokenLabel(string $kind, string $prefix): string
  147. {
  148. return $kind . ':' . $prefix;
  149. }
  150. }