TokensController.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Audit\AuditAction;
  5. use App\Domain\Audit\AuditEmitter;
  6. use App\Domain\Auth\Role;
  7. use App\Domain\Auth\TokenHasher;
  8. use App\Domain\Auth\TokenIssuer;
  9. use App\Domain\Auth\TokenKind;
  10. use App\Domain\Time\Clock;
  11. use App\Infrastructure\Auth\TokenRecord;
  12. use App\Infrastructure\Auth\TokenRepository;
  13. use App\Infrastructure\Consumer\ConsumerRepository;
  14. use App\Infrastructure\Reporter\ReporterRepository;
  15. use DateTimeImmutable;
  16. use DateTimeZone;
  17. use Psr\Http\Message\ResponseInterface;
  18. use Psr\Http\Message\ServerRequestInterface;
  19. /**
  20. * Admin token CRUD. Three creatable kinds — `reporter`, `consumer`,
  21. * `admin` — each with its own constraint:
  22. *
  23. * reporter → reporter_id required, no role / consumer_id
  24. * consumer → consumer_id required, no role / reporter_id
  25. * admin → role required, no FKs
  26. *
  27. * `service` is rejected with 400; service tokens come from the bootstrap
  28. * path only. The list endpoint filters service-kind out unconditionally.
  29. *
  30. * The raw token string appears **only** in the create response — we
  31. * persist its SHA-256 hash and forget the rest.
  32. */
  33. final class TokensController
  34. {
  35. use AdminControllerSupport;
  36. public function __construct(
  37. private readonly TokenRepository $tokens,
  38. private readonly TokenIssuer $issuer,
  39. private readonly TokenHasher $hasher,
  40. private readonly ReporterRepository $reporters,
  41. private readonly ConsumerRepository $consumers,
  42. private readonly Clock $clock,
  43. private readonly AuditEmitter $audit,
  44. ) {
  45. }
  46. public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  47. {
  48. $page = self::pagination($request);
  49. $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
  50. $total = $this->tokens->countNonService();
  51. $data = array_map(static function (TokenRecord $r): array {
  52. return [
  53. 'id' => $r->id,
  54. 'kind' => $r->kind->value,
  55. 'prefix' => $r->prefix,
  56. 'reporter_id' => $r->reporterId,
  57. 'consumer_id' => $r->consumerId,
  58. 'role' => $r->role?->value,
  59. 'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'),
  60. 'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'),
  61. 'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'),
  62. ];
  63. }, $rows);
  64. return self::json($response, 200, [
  65. 'data' => $data,
  66. 'page' => $page['page'],
  67. 'limit' => $page['limit'],
  68. 'total' => $total,
  69. ]);
  70. }
  71. public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  72. {
  73. $body = self::jsonBody($request);
  74. $errors = [];
  75. $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
  76. $kind = TokenKind::tryFrom($kindValue);
  77. if ($kind === null) {
  78. return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']);
  79. }
  80. if ($kind === TokenKind::Service) {
  81. return self::error($response, 400, 'service tokens cannot be created via API');
  82. }
  83. $reporterId = self::optInt($body['reporter_id'] ?? null);
  84. $consumerId = self::optInt($body['consumer_id'] ?? null);
  85. $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null;
  86. $role = $roleValue !== null ? Role::tryFrom($roleValue) : null;
  87. $expiresAt = null;
  88. if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
  89. if (!is_string($body['expires_at'])) {
  90. $errors['expires_at'] = 'must be ISO 8601 string';
  91. } else {
  92. $parsed = self::parseUtc($body['expires_at']);
  93. if ($parsed === null) {
  94. $errors['expires_at'] = 'must be ISO 8601 string';
  95. } elseif ($parsed <= $this->clock->now()) {
  96. $errors['expires_at'] = 'must be in the future';
  97. } else {
  98. $expiresAt = $parsed;
  99. }
  100. }
  101. }
  102. switch ($kind) {
  103. case TokenKind::Reporter:
  104. if ($reporterId === null) {
  105. $errors['reporter_id'] = 'required for reporter tokens';
  106. } elseif ($this->reporters->findById($reporterId) === null) {
  107. $errors['reporter_id'] = 'unknown reporter';
  108. }
  109. if ($consumerId !== null) {
  110. $errors['consumer_id'] = 'must be null for reporter tokens';
  111. }
  112. if ($roleValue !== null) {
  113. $errors['role'] = 'must be null for reporter tokens';
  114. }
  115. break;
  116. case TokenKind::Consumer:
  117. if ($consumerId === null) {
  118. $errors['consumer_id'] = 'required for consumer tokens';
  119. } elseif ($this->consumers->findById($consumerId) === null) {
  120. $errors['consumer_id'] = 'unknown consumer';
  121. }
  122. if ($reporterId !== null) {
  123. $errors['reporter_id'] = 'must be null for consumer tokens';
  124. }
  125. if ($roleValue !== null) {
  126. $errors['role'] = 'must be null for consumer tokens';
  127. }
  128. break;
  129. case TokenKind::Admin:
  130. if ($role === null) {
  131. $errors['role'] = 'required for admin tokens (viewer|operator|admin)';
  132. }
  133. if ($reporterId !== null) {
  134. $errors['reporter_id'] = 'must be null for admin tokens';
  135. }
  136. if ($consumerId !== null) {
  137. $errors['consumer_id'] = 'must be null for admin tokens';
  138. }
  139. break;
  140. default:
  141. // Unreachable; service was already rejected.
  142. break;
  143. }
  144. if ($errors !== []) {
  145. return self::validationFailed($response, $errors);
  146. }
  147. $raw = $this->issuer->issue($kind);
  148. $hash = $this->hasher->hash($raw);
  149. $prefix = substr($raw, 0, 8);
  150. $this->tokens->create(new TokenRecord(
  151. id: null,
  152. kind: $kind,
  153. hash: $hash,
  154. prefix: $prefix,
  155. reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
  156. consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
  157. role: $kind === TokenKind::Admin ? $role : null,
  158. expiresAt: $expiresAt,
  159. revokedAt: null,
  160. lastUsedAt: null,
  161. ));
  162. $created = $this->tokens->findByHashIncludingInvalid($hash);
  163. if ($created === null) {
  164. return self::error($response, 500, 'create_failed');
  165. }
  166. // Audit payload deliberately excludes the raw token. Prefix is OK.
  167. $this->audit->emit(
  168. AuditAction::TOKEN_CREATED,
  169. 'token',
  170. $created->id,
  171. [
  172. 'kind' => $created->kind->value,
  173. 'prefix' => $created->prefix,
  174. 'reporter_id' => $created->reporterId,
  175. 'consumer_id' => $created->consumerId,
  176. 'role' => $created->role?->value,
  177. 'expires_at' => $created->expiresAt?->format('c'),
  178. ],
  179. self::auditContext($request),
  180. self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
  181. );
  182. return self::json($response, 201, [
  183. 'id' => $created->id,
  184. 'kind' => $created->kind->value,
  185. 'prefix' => $created->prefix,
  186. 'reporter_id' => $created->reporterId,
  187. 'consumer_id' => $created->consumerId,
  188. 'role' => $created->role?->value,
  189. 'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'),
  190. 'raw_token' => $raw,
  191. ]);
  192. }
  193. /**
  194. * @param array{id: string} $args
  195. */
  196. public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  197. {
  198. $id = self::parseId($args['id']);
  199. if ($id === null) {
  200. return self::error($response, 404, 'not_found');
  201. }
  202. $token = $this->tokens->findById($id);
  203. if ($token === null) {
  204. return self::error($response, 404, 'not_found');
  205. }
  206. if ($token->kind === TokenKind::Service) {
  207. return self::error($response, 403, 'cannot revoke service tokens via API');
  208. }
  209. $this->tokens->revoke($id, $this->clock->now());
  210. $this->audit->emit(
  211. AuditAction::TOKEN_REVOKED,
  212. 'token',
  213. $id,
  214. ['kind' => $token->kind->value, 'prefix' => $token->prefix],
  215. self::auditContext($request),
  216. self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
  217. );
  218. return $response->withStatus(204);
  219. }
  220. private static function optInt(mixed $value): ?int
  221. {
  222. if (is_int($value) && $value > 0) {
  223. return $value;
  224. }
  225. if (is_string($value) && ctype_digit($value) && $value !== '0') {
  226. return (int) $value;
  227. }
  228. return null;
  229. }
  230. private static function parseUtc(string $iso): ?DateTimeImmutable
  231. {
  232. try {
  233. return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
  234. } catch (\Exception) {
  235. return null;
  236. }
  237. }
  238. private static function parseId(string $raw): ?int
  239. {
  240. return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
  241. }
  242. /**
  243. * Compose a human-friendly label like "admin viewer (abc12345…)" or
  244. * "consumer (zxcvbnm9…)". Includes the role for admin tokens so the
  245. * audit reader can tell a viewer-token revoke from an admin-token revoke.
  246. */
  247. private static function tokenLabel(string $kind, string $prefix, ?string $role): string
  248. {
  249. $base = $kind === 'admin' && $role !== null ? sprintf('admin %s', $role) : $kind;
  250. return sprintf('%s (%s…)', $base, $prefix);
  251. }
  252. }