| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Admin;
- use App\Domain\Audit\AuditAction;
- use App\Domain\Audit\AuditEmitter;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenHasher;
- use App\Domain\Auth\TokenIssuer;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Time\Clock;
- use App\Infrastructure\Auth\TokenRecord;
- use App\Infrastructure\Auth\TokenRepository;
- use App\Infrastructure\Consumer\ConsumerRepository;
- use App\Infrastructure\Reporter\ReporterRepository;
- use DateTimeImmutable;
- use DateTimeZone;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * Admin token CRUD. Three creatable kinds — `reporter`, `consumer`,
- * `admin` — each with its own constraint:
- *
- * reporter → reporter_id required, no role / consumer_id
- * consumer → consumer_id required, no role / reporter_id
- * admin → role required, no FKs
- *
- * `service` is rejected with 400; service tokens come from the bootstrap
- * path only. The list endpoint filters service-kind out unconditionally.
- *
- * The raw token string appears **only** in the create response — we
- * persist its SHA-256 hash and forget the rest.
- */
- final class TokensController
- {
- use AdminControllerSupport;
- public function __construct(
- private readonly TokenRepository $tokens,
- private readonly TokenIssuer $issuer,
- private readonly TokenHasher $hasher,
- private readonly ReporterRepository $reporters,
- private readonly ConsumerRepository $consumers,
- private readonly Clock $clock,
- private readonly AuditEmitter $audit,
- ) {
- }
- public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $page = self::pagination($request);
- $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
- $total = $this->tokens->countNonService();
- $data = array_map(static function (TokenRecord $r): array {
- return [
- 'id' => $r->id,
- 'kind' => $r->kind->value,
- 'prefix' => $r->prefix,
- 'reporter_id' => $r->reporterId,
- 'consumer_id' => $r->consumerId,
- 'role' => $r->role?->value,
- 'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'),
- 'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'),
- 'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'),
- ];
- }, $rows);
- return self::json($response, 200, [
- 'data' => $data,
- 'page' => $page['page'],
- 'limit' => $page['limit'],
- 'total' => $total,
- ]);
- }
- public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $body = self::jsonBody($request);
- $errors = [];
- $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
- $kind = TokenKind::tryFrom($kindValue);
- if ($kind === null) {
- return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']);
- }
- if ($kind === TokenKind::Service) {
- return self::error($response, 400, 'service tokens cannot be created via API');
- }
- $reporterId = self::optInt($body['reporter_id'] ?? null);
- $consumerId = self::optInt($body['consumer_id'] ?? null);
- $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null;
- $role = $roleValue !== null ? Role::tryFrom($roleValue) : null;
- $expiresAt = null;
- if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
- if (!is_string($body['expires_at'])) {
- $errors['expires_at'] = 'must be ISO 8601 string';
- } else {
- $parsed = self::parseUtc($body['expires_at']);
- if ($parsed === null) {
- $errors['expires_at'] = 'must be ISO 8601 string';
- } elseif ($parsed <= $this->clock->now()) {
- $errors['expires_at'] = 'must be in the future';
- } else {
- $expiresAt = $parsed;
- }
- }
- }
- switch ($kind) {
- case TokenKind::Reporter:
- if ($reporterId === null) {
- $errors['reporter_id'] = 'required for reporter tokens';
- } elseif ($this->reporters->findById($reporterId) === null) {
- $errors['reporter_id'] = 'unknown reporter';
- }
- if ($consumerId !== null) {
- $errors['consumer_id'] = 'must be null for reporter tokens';
- }
- if ($roleValue !== null) {
- $errors['role'] = 'must be null for reporter tokens';
- }
- break;
- case TokenKind::Consumer:
- if ($consumerId === null) {
- $errors['consumer_id'] = 'required for consumer tokens';
- } elseif ($this->consumers->findById($consumerId) === null) {
- $errors['consumer_id'] = 'unknown consumer';
- }
- if ($reporterId !== null) {
- $errors['reporter_id'] = 'must be null for consumer tokens';
- }
- if ($roleValue !== null) {
- $errors['role'] = 'must be null for consumer tokens';
- }
- break;
- case TokenKind::Admin:
- if ($role === null) {
- $errors['role'] = 'required for admin tokens (viewer|operator|admin)';
- }
- if ($reporterId !== null) {
- $errors['reporter_id'] = 'must be null for admin tokens';
- }
- if ($consumerId !== null) {
- $errors['consumer_id'] = 'must be null for admin tokens';
- }
- break;
- default:
- // Unreachable; service was already rejected.
- break;
- }
- if ($errors !== []) {
- return self::validationFailed($response, $errors);
- }
- $raw = $this->issuer->issue($kind);
- $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');
- }
- // 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),
- );
- return self::json($response, 201, [
- 'id' => $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('Y-m-d\TH:i:s\Z'),
- 'raw_token' => $raw,
- ]);
- }
- /**
- * @param array{id: string} $args
- */
- public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
- {
- $id = self::parseId($args['id']);
- if ($id === null) {
- return self::error($response, 404, 'not_found');
- }
- $token = $this->tokens->findById($id);
- if ($token === null) {
- return self::error($response, 404, 'not_found');
- }
- if ($token->kind === TokenKind::Service) {
- 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),
- );
- return $response->withStatus(204);
- }
- private static function optInt(mixed $value): ?int
- {
- if (is_int($value) && $value > 0) {
- return $value;
- }
- if (is_string($value) && ctype_digit($value) && $value !== '0') {
- return (int) $value;
- }
- return null;
- }
- private static function parseUtc(string $iso): ?DateTimeImmutable
- {
- try {
- return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
- } catch (\Exception) {
- return null;
- }
- }
- private static function parseId(string $raw): ?int
- {
- return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
- }
- /**
- * Compose a human-friendly label like "admin viewer (abc12345…)" or
- * "consumer (zxcvbnm9…)". Includes the role for admin tokens so the
- * audit reader can tell a viewer-token revoke from an admin-token revoke.
- */
- private static function tokenLabel(string $kind, string $prefix, ?string $role): string
- {
- $base = $kind === 'admin' && $role !== null ? sprintf('admin %s', $role) : $kind;
- return sprintf('%s (%s…)', $base, $prefix);
- }
- }
|