| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- <?php
- declare(strict_types=1);
- namespace App\Infrastructure\Auth;
- use App\Domain\Audit\AuditAction;
- use App\Domain\Audit\AuditContext;
- use App\Domain\Audit\AuditEmitter;
- use App\Domain\Auth\Token;
- use App\Domain\Auth\TokenHasher;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Time\Clock;
- use Doctrine\DBAL\Connection;
- use Psr\Log\LoggerInterface;
- /**
- * Ensures the api_tokens table contains a row matching the configured
- * UI_SERVICE_TOKEN. Idempotent — safe to call on every container start.
- *
- * - empty env → log a warning and skip (early-bring-up scenario).
- * - hash matches an existing service-kind row → no-op.
- * - hash absent → insert.
- * - hash absent but a *different* service-kind row exists (rotation):
- * atomically revoke every previously-valid service-kind row and insert
- * the new one. Emits `token.revoked` (per old) and `token.created` (new)
- * audit rows attributed to `system` (SEC_REVIEW F13). The transaction
- * keeps "no service token exists" out of any observable state — if the
- * insert fails, the revokes roll back too, leaving the prior token
- * valid.
- */
- final class ServiceTokenBootstrap
- {
- public function __construct(
- private readonly TokenRepository $tokens,
- private readonly TokenHasher $hasher,
- private readonly LoggerInterface $logger,
- private readonly Connection $connection,
- private readonly AuditEmitter $audit,
- private readonly Clock $clock,
- ) {
- }
- public function bootstrap(string $rawToken): void
- {
- if ($rawToken === '') {
- $this->logger->warning('UI_SERVICE_TOKEN is empty; skipping service-token bootstrap');
- return;
- }
- $parsed = Token::parse($rawToken);
- if ($parsed === null || $parsed->kind !== TokenKind::Service) {
- $this->logger->warning('UI_SERVICE_TOKEN is not a valid `irdb_svc_…` token; skipping');
- return;
- }
- $hash = $this->hasher->hash($rawToken);
- $existing = $this->tokens->findByHashIncludingInvalid($hash);
- if ($existing !== null) {
- if ($existing->kind === TokenKind::Service) {
- if ($existing->revokedAt !== null) {
- // Operator explicitly revoked this hash, then put it
- // back in env. Refuse to silently un-revoke; force the
- // operator to issue a fresh value.
- $this->logger->error(
- 'UI_SERVICE_TOKEN matches a previously-revoked row; refusing to re-enable. '
- . 'Issue a fresh token value.'
- );
- return;
- }
- $this->logger->info('UI_SERVICE_TOKEN already provisioned; no-op');
- return;
- }
- // A non-service token with the same hash should be impossible
- // (160 bits of entropy). If it ever happens, refuse to clobber.
- $this->logger->error('UI_SERVICE_TOKEN hash collides with non-service row; refusing to insert');
- return;
- }
- // No existing row by exact hash. SEC_REVIEW F13: if there are any
- // currently-valid service-kind rows, we are in a rotation —
- // revoke them all atomically with the new insert so the previously
- // valid hash cannot authenticate after this bootstrap returns.
- $previouslyActive = $this->tokens->findActiveServiceTokens();
- $newPrefix = $parsed->prefix();
- $auditCtx = AuditContext::system();
- $now = $this->clock->now();
- $this->connection->transactional(function () use ($previouslyActive, $hash, $newPrefix, $auditCtx, $now): void {
- foreach ($previouslyActive as $old) {
- if ($old->id === null) {
- // Repository hydrates persisted rows; id is always set.
- throw new \LogicException('persisted TokenRecord must have id');
- }
- $this->tokens->revoke($old->id, $now);
- $this->audit->emitOrThrow(
- AuditAction::TOKEN_REVOKED,
- 'token',
- $old->id,
- [
- 'kind' => $old->kind->value,
- 'prefix' => $old->prefix,
- 'reason' => 'rotated_by_bootstrap',
- ],
- $auditCtx,
- self::tokenLabel($old->kind->value, $old->prefix),
- );
- }
- $this->tokens->create(new TokenRecord(
- id: null,
- kind: TokenKind::Service,
- hash: $hash,
- prefix: $newPrefix,
- reporterId: null,
- consumerId: null,
- role: null,
- expiresAt: null,
- revokedAt: null,
- lastUsedAt: null,
- ));
- $created = $this->tokens->findByHashIncludingInvalid($hash);
- if ($created === null) {
- throw new \RuntimeException('service token not retrievable after insert');
- }
- $this->audit->emitOrThrow(
- AuditAction::TOKEN_CREATED,
- 'token',
- $created->id,
- [
- 'kind' => $created->kind->value,
- 'prefix' => $created->prefix,
- 'source' => 'bootstrap',
- 'rotated_from' => array_map(static fn (TokenRecord $r): string => $r->prefix, $previouslyActive),
- ],
- $auditCtx,
- self::tokenLabel($created->kind->value, $created->prefix),
- );
- });
- if ($previouslyActive !== []) {
- $this->logger->warning(
- 'UI_SERVICE_TOKEN rotated; revoked previously-valid service token(s) and inserted new one',
- [
- 'revoked_count' => count($previouslyActive),
- 'revoked_prefixes' => array_map(
- static fn (TokenRecord $r): string => $r->prefix,
- $previouslyActive,
- ),
- 'new_prefix' => $newPrefix,
- ],
- );
- return;
- }
- $this->logger->info('UI_SERVICE_TOKEN provisioned');
- }
- private static function tokenLabel(string $kind, string $prefix): string
- {
- return $kind . ':' . $prefix;
- }
- }
|