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; } }