ConsumersController.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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\Infrastructure\Consumer\ConsumerRepository;
  7. use Doctrine\DBAL\Connection;
  8. use Psr\Http\Message\ResponseInterface;
  9. use Psr\Http\Message\ServerRequestInterface;
  10. /**
  11. * Admin CRUD over consumers. Mirrors ReportersController's shape; the
  12. * differences are `policy_id` (FK validated against `policies`) and the
  13. * delete-on-active-tokens consideration: the api_tokens FK is CASCADE,
  14. * so dropping a consumer revokes its tokens automatically. We still do
  15. * a soft delete by default to match reporters.
  16. */
  17. final class ConsumersController
  18. {
  19. use AdminControllerSupport;
  20. public function __construct(
  21. private readonly ConsumerRepository $consumers,
  22. private readonly AuditEmitter $audit,
  23. private readonly Connection $connection,
  24. ) {
  25. }
  26. public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  27. {
  28. $page = self::pagination($request);
  29. $rows = $this->consumers->list($page['limit'], $page['offset']);
  30. $total = $this->consumers->count();
  31. return self::json($response, 200, [
  32. 'data' => array_map(static fn ($c) => $c->toArray(), $rows),
  33. 'page' => $page['page'],
  34. 'limit' => $page['limit'],
  35. 'total' => $total,
  36. ]);
  37. }
  38. /**
  39. * @param array{id: string} $args
  40. */
  41. public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  42. {
  43. $id = self::parseId($args['id']);
  44. if ($id === null) {
  45. return self::error($response, 404, 'not_found');
  46. }
  47. $consumer = $this->consumers->findById($id);
  48. if ($consumer === null) {
  49. return self::error($response, 404, 'not_found');
  50. }
  51. return self::json($response, 200, $consumer->toArray());
  52. }
  53. public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  54. {
  55. $body = self::jsonBody($request);
  56. $errors = [];
  57. $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
  58. if ($name === '' || strlen($name) > 128) {
  59. $errors['name'] = 'required, 1–128 chars';
  60. }
  61. $description = null;
  62. if (array_key_exists('description', $body)) {
  63. if ($body['description'] !== null && !is_string($body['description'])) {
  64. $errors['description'] = 'must be string or null';
  65. } else {
  66. $description = $body['description'];
  67. }
  68. }
  69. $policyId = null;
  70. if (!array_key_exists('policy_id', $body) || !is_int($body['policy_id'])) {
  71. // Accept numeric string too — JSON usually has int but be lenient.
  72. if (isset($body['policy_id']) && is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
  73. $policyId = (int) $body['policy_id'];
  74. } else {
  75. $errors['policy_id'] = 'required, integer';
  76. }
  77. } else {
  78. $policyId = $body['policy_id'];
  79. }
  80. if ($policyId !== null && $policyId > 0 && !$this->consumers->policyExists($policyId)) {
  81. $errors['policy_id'] = 'unknown policy';
  82. }
  83. if ($errors === [] && $this->consumers->findByName($name) !== null) {
  84. $errors['name'] = 'already exists';
  85. }
  86. if ($errors !== []) {
  87. return self::validationFailed($response, $errors);
  88. }
  89. /** @var int $policyId */
  90. $userId = self::actingUserId($request);
  91. $auditCtx = self::auditContext($request);
  92. $id = $this->connection->transactional(function () use ($name, $description, $policyId, $userId, $auditCtx): int {
  93. $id = $this->consumers->create($name, $description, $policyId, $userId);
  94. $this->audit->emitOrThrow(
  95. AuditAction::CONSUMER_CREATED,
  96. 'consumer',
  97. $id,
  98. ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
  99. $auditCtx,
  100. $name,
  101. );
  102. return $id;
  103. });
  104. $created = $this->consumers->findById($id);
  105. if ($created === null) {
  106. return self::error($response, 500, 'create_failed');
  107. }
  108. return self::json($response, 201, $created->toArray());
  109. }
  110. /**
  111. * @param array{id: string} $args
  112. */
  113. public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  114. {
  115. $id = self::parseId($args['id']);
  116. if ($id === null) {
  117. return self::error($response, 404, 'not_found');
  118. }
  119. $existing = $this->consumers->findById($id);
  120. if ($existing === null) {
  121. return self::error($response, 404, 'not_found');
  122. }
  123. $body = self::jsonBody($request);
  124. $errors = [];
  125. $fields = [];
  126. if (array_key_exists('name', $body)) {
  127. if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
  128. $errors['name'] = 'required, 1–128 chars';
  129. } else {
  130. $name = trim($body['name']);
  131. $other = $this->consumers->findByName($name);
  132. if ($other !== null && $other->id !== $id) {
  133. $errors['name'] = 'already exists';
  134. } else {
  135. $fields['name'] = $name;
  136. }
  137. }
  138. }
  139. if (array_key_exists('description', $body)) {
  140. if ($body['description'] !== null && !is_string($body['description'])) {
  141. $errors['description'] = 'must be string or null';
  142. } else {
  143. $fields['description'] = $body['description'];
  144. }
  145. }
  146. if (array_key_exists('policy_id', $body)) {
  147. $newPolicy = null;
  148. if (is_int($body['policy_id'])) {
  149. $newPolicy = $body['policy_id'];
  150. } elseif (is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
  151. $newPolicy = (int) $body['policy_id'];
  152. }
  153. if ($newPolicy === null || $newPolicy <= 0) {
  154. $errors['policy_id'] = 'must be positive integer';
  155. } elseif (!$this->consumers->policyExists($newPolicy)) {
  156. $errors['policy_id'] = 'unknown policy';
  157. } else {
  158. $fields['policy_id'] = $newPolicy;
  159. }
  160. }
  161. if (array_key_exists('is_active', $body)) {
  162. if (!is_bool($body['is_active'])) {
  163. $errors['is_active'] = 'must be boolean';
  164. } else {
  165. $fields['is_active'] = $body['is_active'] ? 1 : 0;
  166. }
  167. }
  168. if (array_key_exists('audit_enabled', $body)) {
  169. if (!is_bool($body['audit_enabled'])) {
  170. $errors['audit_enabled'] = 'must be boolean';
  171. } else {
  172. $fields['audit_enabled'] = $body['audit_enabled'] ? 1 : 0;
  173. }
  174. }
  175. if ($errors !== []) {
  176. return self::validationFailed($response, $errors);
  177. }
  178. $beforeSnapshot = [
  179. 'name' => $existing->name,
  180. 'description' => $existing->description,
  181. 'policy_id' => $existing->policyId,
  182. 'is_active' => $existing->isActive ? 1 : 0,
  183. 'audit_enabled' => $existing->auditEnabled ? 1 : 0,
  184. ];
  185. // SEC_REVIEW F41: dedicated alertable audit signal whenever
  186. // `audit_enabled` actually flips. The regular `consumer.updated`
  187. // row also fires (carrying the full field diff for context).
  188. $auditToggleTo = null;
  189. if (array_key_exists('audit_enabled', $fields)
  190. && $fields['audit_enabled'] !== ($existing->auditEnabled ? 1 : 0)
  191. ) {
  192. $auditToggleTo = $fields['audit_enabled'] === 1;
  193. }
  194. $auditCtx = self::auditContext($request);
  195. $this->connection->transactional(function () use ($id, $fields, $existing, $beforeSnapshot, $auditCtx, $auditToggleTo): void {
  196. $this->consumers->update($id, $fields);
  197. $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
  198. $this->audit->emitOrThrow(
  199. AuditAction::CONSUMER_UPDATED,
  200. 'consumer',
  201. $id,
  202. ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
  203. $auditCtx,
  204. $updatedName,
  205. );
  206. if ($auditToggleTo !== null) {
  207. $this->audit->emitOrThrow(
  208. AuditAction::CONSUMER_AUDIT_TOGGLED,
  209. 'consumer',
  210. $id,
  211. [
  212. 'name' => $existing->name,
  213. 'from' => $beforeSnapshot['audit_enabled'] === 1,
  214. 'to' => $auditToggleTo,
  215. ],
  216. $auditCtx,
  217. $updatedName,
  218. );
  219. }
  220. });
  221. $updated = $this->consumers->findById($id);
  222. if ($updated === null) {
  223. return self::error($response, 500, 'update_failed');
  224. }
  225. return self::json($response, 200, $updated->toArray());
  226. }
  227. /**
  228. * @param array{id: string} $args
  229. */
  230. public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  231. {
  232. $id = self::parseId($args['id']);
  233. if ($id === null) {
  234. return self::error($response, 404, 'not_found');
  235. }
  236. $existing = $this->consumers->findById($id);
  237. if ($existing === null) {
  238. return self::error($response, 404, 'not_found');
  239. }
  240. $auditCtx = self::auditContext($request);
  241. $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
  242. $this->consumers->softDelete($id);
  243. $this->audit->emitOrThrow(
  244. AuditAction::CONSUMER_DELETED,
  245. 'consumer',
  246. $id,
  247. ['name' => $existing->name, 'soft' => true],
  248. $auditCtx,
  249. $existing->name,
  250. );
  251. });
  252. return $response->withStatus(204);
  253. }
  254. private static function parseId(string $raw): ?int
  255. {
  256. return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
  257. }
  258. }