20260505110000_add_user_id_to_api_tokens.php 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. <?php
  2. declare(strict_types=1);
  3. use App\Infrastructure\Db\Migrations\BaseMigration;
  4. /**
  5. * SEC_REVIEW F16: bind admin-kind api_tokens to the issuing user.
  6. *
  7. * The original schema carries `kind` and (for admin tokens) `role`, but no
  8. * issuer attribution. If the admin who minted a token is later disabled,
  9. * demoted, or removed, the token continues to grant its bound role until
  10. * an operator manually revokes it.
  11. *
  12. * Adds a nullable `user_id` column. The MySQL FK uses `ON DELETE CASCADE`:
  13. * a hard-deleted user takes their issued admin tokens with them. SET NULL
  14. * was rejected because reverting a previously-bound row to a NULL user_id
  15. * lets the token re-enter the grandfathered path that
  16. * `TokenAuthenticationMiddleware` allows for legacy / console-issued
  17. * tokens — defeating the F16 defense. On SQLite this migration cannot add
  18. * the FK via ALTER TABLE, so deletion-time enforcement falls back to the
  19. * application layer: when the issuer's row no longer exists,
  20. * `UserRepository::findById` returns null and the middleware refuses the
  21. * token. The asymmetry is acceptable because the system has no API-level
  22. * user-deletion path; both drivers behave identically for the actual
  23. * offboarding paths (disable + role demote).
  24. *
  25. * The column is nullable for two reasons:
  26. * 1. Reporter / consumer / service tokens are not user-scoped.
  27. * 2. Admin tokens minted before this migration (and tokens minted via
  28. * `bin/console tokens:create`, which has no logged-in user) carry NULL
  29. * and are grandfathered — `TokenAuthenticationMiddleware` only
  30. * enforces user-state checks when `user_id` is non-null. Operators who
  31. * want strict binding rotate their pre-existing admin tokens after
  32. * deploying this change.
  33. *
  34. * Index on `user_id` so the admin "tokens" page can resolve issuer labels
  35. * without a per-row N+1 query.
  36. */
  37. final class AddUserIdToApiTokens extends BaseMigration
  38. {
  39. public function up(): void
  40. {
  41. if ($this->isMysql()) {
  42. $this->execute('ALTER TABLE api_tokens ADD COLUMN user_id INT UNSIGNED NULL');
  43. $this->execute(
  44. 'ALTER TABLE api_tokens ADD CONSTRAINT fk_api_tokens_user '
  45. . 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE'
  46. );
  47. $this->execute('CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id)');
  48. return;
  49. }
  50. // SQLite cannot add a FOREIGN KEY via ALTER TABLE. Adding the column
  51. // alone gives us the storage; FK enforcement at insert/update is
  52. // handled at the application layer (TokensController validates the
  53. // acting user exists). Index still useful for issuer-lookup queries.
  54. $this->execute('ALTER TABLE api_tokens ADD COLUMN user_id INTEGER NULL');
  55. $this->execute('CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id)');
  56. }
  57. public function down(): void
  58. {
  59. if ($this->isMysql()) {
  60. $this->execute('ALTER TABLE api_tokens DROP FOREIGN KEY fk_api_tokens_user');
  61. $this->execute('DROP INDEX idx_api_tokens_user_id ON api_tokens');
  62. $this->execute('ALTER TABLE api_tokens DROP COLUMN user_id');
  63. return;
  64. }
  65. $this->execute('DROP INDEX IF EXISTS idx_api_tokens_user_id');
  66. $this->execute('ALTER TABLE api_tokens DROP COLUMN user_id');
  67. }
  68. }