|
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace App\Infrastructure\Auth;
|
|
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\Token;
|
|
|
use App\Domain\Auth\TokenHasher;
|
|
use App\Domain\Auth\TokenHasher;
|
|
|
use App\Domain\Auth\TokenKind;
|
|
use App\Domain\Auth\TokenKind;
|
|
|
|
|
+use App\Domain\Time\Clock;
|
|
|
|
|
+use Doctrine\DBAL\Connection;
|
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -16,10 +21,13 @@ use Psr\Log\LoggerInterface;
|
|
|
* - empty env → log a warning and skip (early-bring-up scenario).
|
|
* - empty env → log a warning and skip (early-bring-up scenario).
|
|
|
* - hash matches an existing service-kind row → no-op.
|
|
* - hash matches an existing service-kind row → no-op.
|
|
|
* - hash absent → insert.
|
|
* - hash absent → insert.
|
|
|
- * - hash absent but a *different* service-kind row exists → log warning
|
|
|
|
|
- * (likely a rotation in progress), insert anyway. The operator must
|
|
|
|
|
- * revoke the old hash via a future tooling change. Auto-revocation here
|
|
|
|
|
- * would risk locking out a misconfigured deploy.
|
|
|
|
|
|
|
+ * - 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
|
|
final class ServiceTokenBootstrap
|
|
|
{
|
|
{
|
|
@@ -27,6 +35,9 @@ final class ServiceTokenBootstrap
|
|
|
private readonly TokenRepository $tokens,
|
|
private readonly TokenRepository $tokens,
|
|
|
private readonly TokenHasher $hasher,
|
|
private readonly TokenHasher $hasher,
|
|
|
private readonly LoggerInterface $logger,
|
|
private readonly LoggerInterface $logger,
|
|
|
|
|
+ private readonly Connection $connection,
|
|
|
|
|
+ private readonly AuditEmitter $audit,
|
|
|
|
|
+ private readonly Clock $clock,
|
|
|
) {
|
|
) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -50,6 +61,18 @@ final class ServiceTokenBootstrap
|
|
|
|
|
|
|
|
if ($existing !== null) {
|
|
if ($existing !== null) {
|
|
|
if ($existing->kind === TokenKind::Service) {
|
|
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');
|
|
$this->logger->info('UI_SERVICE_TOKEN already provisioned; no-op');
|
|
|
|
|
|
|
|
return;
|
|
return;
|
|
@@ -62,29 +85,91 @@ final class ServiceTokenBootstrap
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // No existing row by exact hash. Check if there is a *different*
|
|
|
|
|
- // service-kind row already — typical during rotation.
|
|
|
|
|
- $stmt = $this->tokens->countServiceTokens();
|
|
|
|
|
- if ($stmt > 0) {
|
|
|
|
|
|
|
+ // 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(
|
|
$this->logger->warning(
|
|
|
- 'UI_SERVICE_TOKEN does not match the existing service-kind row(s); '
|
|
|
|
|
- . 'inserting new service token. Operator must revoke the old hash manually.'
|
|
|
|
|
|
|
+ '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,
|
|
|
|
|
+ ],
|
|
|
);
|
|
);
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- $this->tokens->create(new TokenRecord(
|
|
|
|
|
- id: null,
|
|
|
|
|
- kind: TokenKind::Service,
|
|
|
|
|
- hash: $hash,
|
|
|
|
|
- prefix: $parsed->prefix(),
|
|
|
|
|
- reporterId: null,
|
|
|
|
|
- consumerId: null,
|
|
|
|
|
- role: null,
|
|
|
|
|
- expiresAt: null,
|
|
|
|
|
- revokedAt: null,
|
|
|
|
|
- lastUsedAt: null,
|
|
|
|
|
- ));
|
|
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
$this->logger->info('UI_SERVICE_TOKEN provisioned');
|
|
$this->logger->info('UI_SERVICE_TOKEN provisioned');
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private static function tokenLabel(string $kind, string $prefix): string
|
|
|
|
|
+ {
|
|
|
|
|
+ return $kind . ':' . $prefix;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|