|
|
@@ -17,6 +17,7 @@ use App\Infrastructure\Consumer\ConsumerRepository;
|
|
|
use App\Infrastructure\Reporter\ReporterRepository;
|
|
|
use DateTimeImmutable;
|
|
|
use DateTimeZone;
|
|
|
+use Doctrine\DBAL\Connection;
|
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
|
|
@@ -46,6 +47,7 @@ final class TokensController
|
|
|
private readonly ConsumerRepository $consumers,
|
|
|
private readonly Clock $clock,
|
|
|
private readonly AuditEmitter $audit,
|
|
|
+ private readonly Connection $connection,
|
|
|
) {
|
|
|
}
|
|
|
|
|
|
@@ -163,40 +165,45 @@ final class TokensController
|
|
|
$hash = $this->hasher->hash($raw);
|
|
|
$prefix = substr($raw, 0, 8);
|
|
|
|
|
|
- $this->tokens->create(new TokenRecord(
|
|
|
- id: null,
|
|
|
- kind: $kind,
|
|
|
- hash: $hash,
|
|
|
- prefix: $prefix,
|
|
|
- reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
|
|
|
- consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
|
|
|
- role: $kind === TokenKind::Admin ? $role : null,
|
|
|
- expiresAt: $expiresAt,
|
|
|
- revokedAt: null,
|
|
|
- lastUsedAt: null,
|
|
|
- ));
|
|
|
-
|
|
|
- $created = $this->tokens->findByHashIncludingInvalid($hash);
|
|
|
- if ($created === null) {
|
|
|
- return self::error($response, 500, 'create_failed');
|
|
|
- }
|
|
|
+ $auditCtx = self::auditContext($request);
|
|
|
+ $created = $this->connection->transactional(function () use ($kind, $hash, $prefix, $reporterId, $consumerId, $role, $expiresAt, $auditCtx): TokenRecord {
|
|
|
+ $this->tokens->create(new TokenRecord(
|
|
|
+ id: null,
|
|
|
+ kind: $kind,
|
|
|
+ hash: $hash,
|
|
|
+ prefix: $prefix,
|
|
|
+ reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
|
|
|
+ consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
|
|
|
+ role: $kind === TokenKind::Admin ? $role : null,
|
|
|
+ expiresAt: $expiresAt,
|
|
|
+ revokedAt: null,
|
|
|
+ lastUsedAt: null,
|
|
|
+ ));
|
|
|
+
|
|
|
+ $created = $this->tokens->findByHashIncludingInvalid($hash);
|
|
|
+ if ($created === null) {
|
|
|
+ throw new \RuntimeException('token not retrievable after insert');
|
|
|
+ }
|
|
|
|
|
|
- // Audit payload deliberately excludes the raw token. Prefix is OK.
|
|
|
- $this->audit->emit(
|
|
|
- AuditAction::TOKEN_CREATED,
|
|
|
- 'token',
|
|
|
- $created->id,
|
|
|
- [
|
|
|
- 'kind' => $created->kind->value,
|
|
|
- 'prefix' => $created->prefix,
|
|
|
- 'reporter_id' => $created->reporterId,
|
|
|
- 'consumer_id' => $created->consumerId,
|
|
|
- 'role' => $created->role?->value,
|
|
|
- 'expires_at' => $created->expiresAt?->format('c'),
|
|
|
- ],
|
|
|
- self::auditContext($request),
|
|
|
- self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
|
|
|
- );
|
|
|
+ // Audit payload deliberately excludes the raw token. Prefix is OK.
|
|
|
+ $this->audit->emitOrThrow(
|
|
|
+ AuditAction::TOKEN_CREATED,
|
|
|
+ 'token',
|
|
|
+ $created->id,
|
|
|
+ [
|
|
|
+ 'kind' => $created->kind->value,
|
|
|
+ 'prefix' => $created->prefix,
|
|
|
+ 'reporter_id' => $created->reporterId,
|
|
|
+ 'consumer_id' => $created->consumerId,
|
|
|
+ 'role' => $created->role?->value,
|
|
|
+ 'expires_at' => $created->expiresAt?->format('c'),
|
|
|
+ ],
|
|
|
+ $auditCtx,
|
|
|
+ self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
|
|
|
+ );
|
|
|
+
|
|
|
+ return $created;
|
|
|
+ });
|
|
|
|
|
|
return self::json($response, 201, [
|
|
|
'id' => $created->id,
|
|
|
@@ -227,16 +234,19 @@ final class TokensController
|
|
|
return self::error($response, 403, 'cannot revoke service tokens via API');
|
|
|
}
|
|
|
|
|
|
- $this->tokens->revoke($id, $this->clock->now());
|
|
|
-
|
|
|
- $this->audit->emit(
|
|
|
- AuditAction::TOKEN_REVOKED,
|
|
|
- 'token',
|
|
|
- $id,
|
|
|
- ['kind' => $token->kind->value, 'prefix' => $token->prefix],
|
|
|
- self::auditContext($request),
|
|
|
- self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
|
|
|
- );
|
|
|
+ $auditCtx = self::auditContext($request);
|
|
|
+ $now = $this->clock->now();
|
|
|
+ $this->connection->transactional(function () use ($id, $token, $auditCtx, $now): void {
|
|
|
+ $this->tokens->revoke($id, $now);
|
|
|
+ $this->audit->emitOrThrow(
|
|
|
+ AuditAction::TOKEN_REVOKED,
|
|
|
+ 'token',
|
|
|
+ $id,
|
|
|
+ ['kind' => $token->kind->value, 'prefix' => $token->prefix],
|
|
|
+ $auditCtx,
|
|
|
+ self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
|
|
|
+ );
|
|
|
+ });
|
|
|
|
|
|
return $response->withStatus(204);
|
|
|
}
|
|
|
@@ -268,16 +278,18 @@ final class TokensController
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
- $this->tokens->deleteRow($id);
|
|
|
-
|
|
|
- $this->audit->emit(
|
|
|
- AuditAction::TOKEN_DELETED,
|
|
|
- 'token',
|
|
|
- $id,
|
|
|
- ['kind' => $token->kind->value, 'prefix' => $token->prefix],
|
|
|
- self::auditContext($request),
|
|
|
- self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
|
|
|
- );
|
|
|
+ $auditCtx = self::auditContext($request);
|
|
|
+ $this->connection->transactional(function () use ($id, $token, $auditCtx): void {
|
|
|
+ $this->tokens->deleteRow($id);
|
|
|
+ $this->audit->emitOrThrow(
|
|
|
+ AuditAction::TOKEN_DELETED,
|
|
|
+ 'token',
|
|
|
+ $id,
|
|
|
+ ['kind' => $token->kind->value, 'prefix' => $token->prefix],
|
|
|
+ $auditCtx,
|
|
|
+ self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
|
|
|
+ );
|
|
|
+ });
|
|
|
|
|
|
return $response->withStatus(204);
|
|
|
}
|