Quellcode durchsuchen

fix: bind admin tokens to issuing user; reject after demote/disable (SEC_REVIEW F16)

Admin-kind api_tokens previously carried `role` but no issuer attribution.
A token minted by an Admin who was later demoted (e.g. role flipped to
Viewer in OIDC), disabled (F11 disabled_at), or hard-removed continued
to grant the bound role until an operator manually revoked it. With no
listing-by-issuer surface, that token was effectively orphaned.

Three layers close the gap:

1. Schema. Migration 20260505110000 adds nullable `api_tokens.user_id`.
   On MySQL the FK uses ON DELETE CASCADE so a hard-deleted user takes
   their tokens with them — SET NULL was rejected because reverting
   to NULL re-enters the legacy / grandfathered path. SQLite cannot
   add an FK via ALTER TABLE; deletion-time enforcement on that
   driver falls back to the application layer (issuer lookup returns
   null → 401). The system has no API-level user-deletion path, so
   both drivers behave identically for the actual offboarding flows
   (disable + role demote).

2. Binding. `TokensController::create` writes the acting user's id
   into the new column for `kind=admin` only. Reporter / consumer /
   service tokens stay user-less (device credentials, not delegated
   user privilege). `TokenRecord` carries the new `userId` field;
   the create response and list response surface `user_id`, the
   list also denormalises a `user_label` (display name → email →
   `user#N`). Admin tokens minted via `bin/console tokens:create`
   carry NULL and are grandfathered.

3. Enforcement. `TokenAuthenticationMiddleware` injects
   UserRepository; for any admin-kind token with non-null user_id
   it loads the issuer and refuses the token (401, same shape as
   every other auth failure) when the issuer row is missing, has
   `disabled_at` set, or has a current role that does not satisfy
   the token's bound role (`role.satisfies(token.role)`). NULL
   user_id skips the check, preserving the grandfathered path.

UI: /app/tokens adds an Issuer column that reads `user_label`
(fallback `user#N` for deleted issuers, em-dash for legacy /
console-issued tokens). OpenAPI yaml + openapi.php document the
new `user_id` and `user_label` fields on the Token schema.

Regression tests in api/tests/Integration/Auth/TokenIssuerBindingTest.php
cover binding-on-create, audit attribution, list label hydration,
happy-path auth, disable rejection, demote rejection (Admin token
held by demoted Viewer issuer → 401), Viewer-token-on-Admin issuer
still works, deleted-issuer rejection on SQLite, and grandfathered
NULL-user-id auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa vor 4 Tagen
Ursprung
Commit
947ab89e04

+ 76 - 0
api/db/migrations/20260505110000_add_user_id_to_api_tokens.php

@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * SEC_REVIEW F16: bind admin-kind api_tokens to the issuing user.
+ *
+ * The original schema carries `kind` and (for admin tokens) `role`, but no
+ * issuer attribution. If the admin who minted a token is later disabled,
+ * demoted, or removed, the token continues to grant its bound role until
+ * an operator manually revokes it.
+ *
+ * Adds a nullable `user_id` column. The MySQL FK uses `ON DELETE CASCADE`:
+ * a hard-deleted user takes their issued admin tokens with them. SET NULL
+ * was rejected because reverting a previously-bound row to a NULL user_id
+ * lets the token re-enter the grandfathered path that
+ * `TokenAuthenticationMiddleware` allows for legacy / console-issued
+ * tokens — defeating the F16 defense. On SQLite this migration cannot add
+ * the FK via ALTER TABLE, so deletion-time enforcement falls back to the
+ * application layer: when the issuer's row no longer exists,
+ * `UserRepository::findById` returns null and the middleware refuses the
+ * token. The asymmetry is acceptable because the system has no API-level
+ * user-deletion path; both drivers behave identically for the actual
+ * offboarding paths (disable + role demote).
+ *
+ * The column is nullable for two reasons:
+ *  1. Reporter / consumer / service tokens are not user-scoped.
+ *  2. Admin tokens minted before this migration (and tokens minted via
+ *     `bin/console tokens:create`, which has no logged-in user) carry NULL
+ *     and are grandfathered — `TokenAuthenticationMiddleware` only
+ *     enforces user-state checks when `user_id` is non-null. Operators who
+ *     want strict binding rotate their pre-existing admin tokens after
+ *     deploying this change.
+ *
+ * Index on `user_id` so the admin "tokens" page can resolve issuer labels
+ * without a per-row N+1 query.
+ */
+final class AddUserIdToApiTokens extends BaseMigration
+{
+    public function up(): void
+    {
+        if ($this->isMysql()) {
+            $this->execute('ALTER TABLE api_tokens ADD COLUMN user_id INT UNSIGNED NULL');
+            $this->execute(
+                'ALTER TABLE api_tokens ADD CONSTRAINT fk_api_tokens_user '
+                . 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE'
+            );
+            $this->execute('CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id)');
+
+            return;
+        }
+
+        // SQLite cannot add a FOREIGN KEY via ALTER TABLE. Adding the column
+        // alone gives us the storage; FK enforcement at insert/update is
+        // handled at the application layer (TokensController validates the
+        // acting user exists). Index still useful for issuer-lookup queries.
+        $this->execute('ALTER TABLE api_tokens ADD COLUMN user_id INTEGER NULL');
+        $this->execute('CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id)');
+    }
+
+    public function down(): void
+    {
+        if ($this->isMysql()) {
+            $this->execute('ALTER TABLE api_tokens DROP FOREIGN KEY fk_api_tokens_user');
+            $this->execute('DROP INDEX idx_api_tokens_user_id ON api_tokens');
+            $this->execute('ALTER TABLE api_tokens DROP COLUMN user_id');
+
+            return;
+        }
+
+        $this->execute('DROP INDEX IF EXISTS idx_api_tokens_user_id');
+        $this->execute('ALTER TABLE api_tokens DROP COLUMN user_id');
+    }
+}

+ 2 - 0
api/openapi.php

@@ -122,6 +122,8 @@ $components = [
                 'reporter_id' => ['type' => 'integer', 'nullable' => true],
                 'consumer_id' => ['type' => 'integer', 'nullable' => true],
                 'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
+                'user_id' => ['type' => 'integer', 'nullable' => true, 'description' => 'For admin-kind tokens issued via the API, the id of the issuing admin. Reporter, consumer, service, and console-issued admin tokens carry null. When non-null, the token is rejected (401) at auth time if the issuer is later disabled or demoted below the token role. (SEC_REVIEW F16.)'],
+                'user_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Denormalised display name (or email) of the issuing user; null when user_id is null or the user has since been deleted. List responses only.'],
                 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                 'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                 'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],

+ 18 - 0
api/public/openapi.yaml

@@ -1623,6 +1623,24 @@ components:
             - operator
             - admin
             - null
+        user_id:
+          type: integer
+          nullable: true
+          description: |
+            For admin-kind tokens issued via the API, the id of the admin
+            user who minted the token. Reporter, consumer, service, and
+            admin tokens minted via `bin/console tokens:create` carry null.
+            When non-null, the token is rejected (401) at auth time if the
+            issuer is later disabled or demoted below the token's role.
+            (SEC_REVIEW F16.)
+        user_label:
+          type: string
+          nullable: true
+          description: |
+            Denormalised display name (or email) of the issuing user;
+            null when `user_id` is null or the user has since been deleted.
+            Surfaced on list responses only — not part of the create
+            response.
         expires_at:
           type: string
           format: date-time

+ 34 - 2
api/src/Application/Admin/TokensController.php

@@ -13,6 +13,7 @@ use App\Domain\Auth\TokenKind;
 use App\Domain\Time\Clock;
 use App\Infrastructure\Auth\TokenRecord;
 use App\Infrastructure\Auth\TokenRepository;
+use App\Infrastructure\Auth\UserRepository;
 use App\Infrastructure\Consumer\ConsumerRepository;
 use App\Infrastructure\Reporter\ReporterRepository;
 use DateTimeImmutable;
@@ -45,6 +46,7 @@ final class TokensController
         private readonly TokenHasher $hasher,
         private readonly ReporterRepository $reporters,
         private readonly ConsumerRepository $consumers,
+        private readonly UserRepository $users,
         private readonly Clock $clock,
         private readonly AuditEmitter $audit,
         private readonly Connection $connection,
@@ -57,7 +59,23 @@ final class TokensController
         $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
         $total = $this->tokens->countNonService();
 
-        $data = array_map(static function (TokenRecord $r): array {
+        // Resolve the unique user ids referenced by these rows so the UI can
+        // render "issued by …" without a per-row N+1. Tokens minted before
+        // SEC_REVIEW F16 (or via `bin/console`) carry user_id=null and are
+        // labelled "—" in the UI.
+        $userIds = array_values(array_unique(array_filter(
+            array_map(static fn (TokenRecord $r): ?int => $r->userId, $rows),
+            static fn (?int $id): bool => $id !== null,
+        )));
+        $userLabels = [];
+        foreach ($userIds as $uid) {
+            $user = $this->users->findById($uid);
+            if ($user !== null) {
+                $userLabels[$uid] = $user->displayName ?? $user->email ?? ('user#' . $uid);
+            }
+        }
+
+        $data = array_map(static function (TokenRecord $r) use ($userLabels): array {
             return [
                 'id' => $r->id,
                 'kind' => $r->kind->value,
@@ -65,6 +83,8 @@ final class TokensController
                 'reporter_id' => $r->reporterId,
                 'consumer_id' => $r->consumerId,
                 'role' => $r->role?->value,
+                'user_id' => $r->userId,
+                'user_label' => $r->userId !== null ? ($userLabels[$r->userId] ?? null) : null,
                 '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'),
@@ -165,8 +185,17 @@ final class TokensController
         $hash = $this->hasher->hash($raw);
         $prefix = substr($raw, 0, 8);
 
+        // SEC_REVIEW F16: admin tokens carry their issuer's user_id so
+        // TokenAuthenticationMiddleware can refuse the token after the
+        // issuer is disabled or demoted below the token's role. Reporter
+        // and consumer tokens stay user-less — they are device credentials,
+        // not delegated user privilege. If no acting user is present
+        // (impossible via the live admin endpoints, defensive for tests),
+        // the token is created unbound and behaves like a legacy admin row.
+        $issuerUserId = $kind === TokenKind::Admin ? self::actingUserId($request) : null;
+
         $auditCtx = self::auditContext($request);
-        $created = $this->connection->transactional(function () use ($kind, $hash, $prefix, $reporterId, $consumerId, $role, $expiresAt, $auditCtx): TokenRecord {
+        $created = $this->connection->transactional(function () use ($kind, $hash, $prefix, $reporterId, $consumerId, $role, $expiresAt, $issuerUserId, $auditCtx): TokenRecord {
             $this->tokens->create(new TokenRecord(
                 id: null,
                 kind: $kind,
@@ -178,6 +207,7 @@ final class TokensController
                 expiresAt: $expiresAt,
                 revokedAt: null,
                 lastUsedAt: null,
+                userId: $issuerUserId,
             ));
 
             $created = $this->tokens->findByHashIncludingInvalid($hash);
@@ -196,6 +226,7 @@ final class TokensController
                     'reporter_id' => $created->reporterId,
                     'consumer_id' => $created->consumerId,
                     'role' => $created->role?->value,
+                    'user_id' => $created->userId,
                     'expires_at' => $created->expiresAt?->format('c'),
                 ],
                 $auditCtx,
@@ -212,6 +243,7 @@ final class TokensController
             'reporter_id' => $created->reporterId,
             'consumer_id' => $created->consumerId,
             'role' => $created->role?->value,
+            'user_id' => $created->userId,
             'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'),
             'raw_token' => $raw,
         ]);

+ 7 - 0
api/src/Infrastructure/Auth/TokenRecord.php

@@ -15,6 +15,12 @@ use DateTimeImmutable;
  * lifetime of the token. Note this carries the SHA-256 hash, never the
  * raw token — the raw is shown to the operator once at creation time and
  * then forgotten by the api.
+ *
+ * `userId` records the admin who minted the token (SEC_REVIEW F16). Set
+ * for admin-kind tokens issued via the API; null for reporter / consumer /
+ * service tokens, for admin tokens minted via `bin/console tokens:create`,
+ * and for legacy admin rows that pre-date this binding. The auth
+ * middleware only enforces user-state checks when this is non-null.
  */
 final class TokenRecord
 {
@@ -29,6 +35,7 @@ final class TokenRecord
         public readonly ?DateTimeImmutable $expiresAt,
         public readonly ?DateTimeImmutable $revokedAt,
         public readonly ?DateTimeImmutable $lastUsedAt,
+        public readonly ?int $userId = null,
     ) {
     }
 }

+ 7 - 5
api/src/Infrastructure/Auth/TokenRepository.php

@@ -28,7 +28,7 @@ final class TokenRepository
     {
         $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
 
-        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
             . 'expires_at, revoked_at, last_used_at '
             . 'FROM api_tokens '
             . 'WHERE token_hash = :hash '
@@ -51,7 +51,7 @@ final class TokenRepository
 
     public function findByHashIncludingInvalid(string $hash): ?TokenRecord
     {
-        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
             . 'expires_at, revoked_at, last_used_at '
             . 'FROM api_tokens WHERE token_hash = :hash LIMIT 1';
 
@@ -74,6 +74,7 @@ final class TokenRepository
             'reporter_id' => $record->reporterId,
             'consumer_id' => $record->consumerId,
             'role' => $record->role?->value,
+            'user_id' => $record->userId,
             'expires_at' => $record->expiresAt?->format('Y-m-d H:i:s'),
             'revoked_at' => $record->revokedAt?->format('Y-m-d H:i:s'),
             'last_used_at' => $record->lastUsedAt?->format('Y-m-d H:i:s'),
@@ -95,7 +96,7 @@ final class TokenRepository
     {
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
-            'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+            'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
             . 'expires_at, revoked_at, last_used_at FROM api_tokens WHERE id = :id',
             ['id' => $id]
         );
@@ -111,7 +112,7 @@ final class TokenRepository
      */
     public function listNonService(int $limit, int $offset): array
     {
-        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
             . 'expires_at, revoked_at, last_used_at FROM api_tokens '
             . 'WHERE kind != :svc ORDER BY id DESC LIMIT :limit OFFSET :offset';
 
@@ -178,7 +179,7 @@ final class TokenRepository
     {
         $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
 
-        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
             . 'expires_at, revoked_at, last_used_at FROM api_tokens '
             . 'WHERE kind = :kind '
             . 'AND revoked_at IS NULL '
@@ -215,6 +216,7 @@ final class TokenRepository
             expiresAt: self::parseDate($row['expires_at'] ?? null),
             revokedAt: self::parseDate($row['revoked_at'] ?? null),
             lastUsedAt: self::parseDate($row['last_used_at'] ?? null),
+            userId: isset($row['user_id']) && $row['user_id'] !== null ? (int) $row['user_id'] : null,
         );
     }
 

+ 29 - 3
api/src/Infrastructure/Http/Middleware/TokenAuthenticationMiddleware.php

@@ -9,6 +9,7 @@ use App\Domain\Auth\Token;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenKind;
 use App\Infrastructure\Auth\TokenRepository;
+use App\Infrastructure\Auth\UserRepository;
 use DateTimeImmutable;
 use DateTimeZone;
 use Psr\Http\Message\ResponseFactoryInterface;
@@ -22,9 +23,17 @@ use Psr\Http\Server\RequestHandlerInterface;
  * AuthenticatedPrincipal to the request, and updates last_used_at.
  *
  * Returns a uniform 401 `{"error":"unauthorized"}` for any failure mode —
- * missing header, malformed token, unknown hash, revoked, expired. The
- * caller can never tell which one. ImpersonationMiddleware and
- * RbacMiddleware run after this and supply 400/403 separately.
+ * missing header, malformed token, unknown hash, revoked, expired,
+ * issuer-disabled, issuer-demoted-below-token-role. The caller can never
+ * tell which one. ImpersonationMiddleware and RbacMiddleware run after
+ * this and supply 400/403 separately.
+ *
+ * SEC_REVIEW F16: admin tokens that carry a non-null `user_id` are
+ * additionally validated against the issuer's current state — disabled or
+ * demoted users do not authenticate via tokens they minted while still
+ * privileged. Tokens with `user_id = null` (legacy admin rows from before
+ * the binding migration, and admin tokens minted via `bin/console
+ * tokens:create`) skip this check; they are grandfathered.
  */
 final class TokenAuthenticationMiddleware implements MiddlewareInterface
 {
@@ -34,6 +43,7 @@ final class TokenAuthenticationMiddleware implements MiddlewareInterface
     public function __construct(
         private readonly TokenRepository $tokens,
         private readonly TokenHasher $hasher,
+        private readonly UserRepository $users,
         private readonly ResponseFactoryInterface $responseFactory,
     ) {
     }
@@ -57,6 +67,22 @@ final class TokenAuthenticationMiddleware implements MiddlewareInterface
             return $this->unauthorized();
         }
 
+        // SEC_REVIEW F16: bound admin tokens follow their issuer's
+        // privilege state. ON DELETE SET NULL on the FK means a token
+        // with a non-null user_id always resolves to a row that existed
+        // when last bound — but that row may now be disabled or demoted.
+        // We refuse the token in either case, indistinguishable from any
+        // other auth failure mode at the HTTP boundary.
+        if ($record->kind === TokenKind::Admin && $record->userId !== null) {
+            $issuer = $this->users->findById($record->userId);
+            if ($issuer === null || $issuer->isDisabled()) {
+                return $this->unauthorized();
+            }
+            if ($record->role !== null && !$issuer->role->satisfies($record->role)) {
+                return $this->unauthorized();
+            }
+        }
+
         // Update last_used_at synchronously. Move to write-behind in M14
         // if perf demands.
         $this->tokens->markUsed((int) $record->id, new DateTimeImmutable('now', new DateTimeZone('UTC')));

+ 202 - 0
api/tests/Integration/Auth/TokenIssuerBindingTest.php

@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * SEC_REVIEW F16: admin tokens carry the issuing user's id; the auth
+ * middleware refuses tokens whose issuer is later disabled or demoted
+ * below the bound role.
+ *
+ * Reporter / consumer / service tokens stay user-less (they are device
+ * credentials, not delegated user privilege) — covered here only to assert
+ * the binding is admin-only.
+ *
+ * Tokens minted before this migration (and via `bin/console
+ * tokens:create`) carry `user_id = NULL`; they are grandfathered, exercise
+ * `testLegacyUnboundAdminTokenStillAuthenticates` below.
+ */
+final class TokenIssuerBindingTest extends AppTestCase
+{
+    public function testAdminTokenCreatedViaApiIsBoundToActingAdmin(): void
+    {
+        $service = $this->createToken(TokenKind::Service);
+        $issuer = $this->createUser(Role::Admin);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            [
+                'Authorization' => 'Bearer ' . $service,
+                'X-Acting-User-Id' => (string) $issuer,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['kind' => 'admin', 'role' => 'operator']) ?: null,
+        );
+        self::assertSame(201, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame($issuer, (int) $body['user_id']);
+
+        // The audit row carries the issuer id too — so a SOC reader can
+        // attribute the mint without joining api_tokens.
+        $auditRow = $this->db->fetchAssociative(
+            "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($auditRow);
+        $details = json_decode((string) $auditRow['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame($issuer, (int) $details['user_id']);
+    }
+
+    public function testReporterTokenCreatedViaApiIsNotBoundToUser(): void
+    {
+        $service = $this->createToken(TokenKind::Service);
+        $issuer = $this->createUser(Role::Admin);
+        $reporterId = $this->createReporter('rep-bind-check');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            [
+                'Authorization' => 'Bearer ' . $service,
+                'X-Acting-User-Id' => (string) $issuer,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
+        );
+        self::assertSame(201, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertArrayHasKey('user_id', $body);
+        self::assertNull($body['user_id']);
+    }
+
+    public function testListSurfacesIssuerLabel(): void
+    {
+        $service = $this->createToken(TokenKind::Service);
+        $issuer = $this->createUser(Role::Admin);
+        $this->db->update('users', ['display_name' => 'Carol Admin'], ['id' => $issuer]);
+
+        // Mint via the API so the binding actually fires.
+        $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            [
+                'Authorization' => 'Bearer ' . $service,
+                'X-Acting-User-Id' => (string) $issuer,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['kind' => 'admin', 'role' => 'viewer']) ?: null,
+        );
+
+        $list = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $service,
+            'X-Acting-User-Id' => (string) $issuer,
+        ]);
+        self::assertSame(200, $list->getStatusCode());
+        $rows = $this->decode($list)['data'];
+
+        $minted = null;
+        foreach ($rows as $row) {
+            if (($row['kind'] ?? null) === 'admin' && ($row['role'] ?? null) === 'viewer') {
+                $minted = $row;
+                break;
+            }
+        }
+        self::assertIsArray($minted, 'minted admin row not present in list');
+        self::assertSame($issuer, (int) $minted['user_id']);
+        self::assertSame('Carol Admin', $minted['user_label']);
+    }
+
+    public function testBoundAdminTokenAuthenticatesWhileIssuerActive(): void
+    {
+        $issuer = $this->createUser(Role::Admin);
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
+
+        // Pick any admin-only endpoint that returns 200 for an Admin role.
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+    }
+
+    public function testBoundAdminTokenIsRejectedAfterIssuerDisabled(): void
+    {
+        $issuer = $this->createUser(Role::Admin);
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
+
+        $this->db->update(
+            'users',
+            ['disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s')],
+            ['id' => $issuer],
+        );
+
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(401, $resp->getStatusCode());
+        self::assertSame('unauthorized', $this->decode($resp)['error']);
+    }
+
+    public function testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole(): void
+    {
+        $issuer = $this->createUser(Role::Admin);
+        // Token grants Admin to whoever holds it.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
+
+        // Issuer demoted to Viewer — the admin-grant token must stop working.
+        $this->db->update('users', ['role' => Role::Viewer->value], ['id' => $issuer]);
+
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(401, $resp->getStatusCode());
+    }
+
+    public function testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole(): void
+    {
+        $issuer = $this->createUser(Role::Admin);
+        // Token grants only Viewer; issuer remains Admin (>= Viewer) → token works.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer, userId: $issuer);
+
+        // Endpoint requires Viewer — admin/jobs/status is Viewer-tier.
+        $resp = $this->request('GET', '/api/v1/admin/jobs/status', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+    }
+
+    public function testBoundAdminTokenIsRejectedIfIssuerRowIsGone(): void
+    {
+        $issuer = $this->createUser(Role::Admin);
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);
+
+        // Simulate a hard delete: the issuer row is removed but the token
+        // row points at the (now-orphaned) user_id. On MySQL the FK CASCADE
+        // would kill the token row first; on SQLite the application-layer
+        // user lookup returns null and the middleware refuses the token.
+        $this->db->delete('users', ['id' => $issuer]);
+
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(401, $resp->getStatusCode());
+    }
+
+    public function testLegacyUnboundAdminTokenStillAuthenticates(): void
+    {
+        // Admin token created without any user_id — legacy / console-issued.
+        // F16 grandfathers these so existing deployments keep working;
+        // operators rotate them after they redeploy.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: null);
+
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+    }
+}

+ 2 - 0
api/tests/Integration/Support/AppTestCase.php

@@ -134,6 +134,7 @@ abstract class AppTestCase extends TestCase
         ?Role $role = null,
         ?int $reporterId = null,
         ?int $consumerId = null,
+        ?int $userId = null,
     ): string {
         /** @var TokenIssuer $issuer */
         $issuer = $this->container->get(TokenIssuer::class);
@@ -154,6 +155,7 @@ abstract class AppTestCase extends TestCase
             expiresAt: null,
             revokedAt: null,
             lastUsedAt: null,
+            userId: $userId,
         ));
 
         return $raw;

+ 11 - 1
ui/resources/views/pages/tokens/index.twig

@@ -68,6 +68,7 @@
                     {{ sort.th('Kind', 'kind') }}
                     {{ sort.th('Prefix', 'prefix') }}
                     {{ sort.th('Role / target', 'role_target') }}
+                    {{ sort.th('Issuer', 'issuer') }}
                     {{ sort.th('Last used', 'last_used', 'date') }}
                     {{ sort.th('Status', 'status') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
@@ -85,6 +86,15 @@
                             {%- elseif t.kind == 'consumer' -%}consumer #{{ t.consumer_id }}
                             {%- else -%}—{%- endif -%}
                         </td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ t.user_label|default('') }}">
+                            {%- if t.user_label -%}
+                                {{ t.user_label }}
+                            {%- elseif t.user_id -%}
+                                <span class="text-slate-400" title="Issuer was deleted">user #{{ t.user_id }}</span>
+                            {%- else -%}
+                                <span class="text-slate-400" title="Token is not bound to a user (legacy or console-issued)">—</span>
+                            {%- endif -%}
+                        </td>
                         <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ t.last_used_at|default('') }}">{% if t.last_used_at %}<time class="irdb-dt" datetime="{{ t.last_used_at }}">{{ t.last_used_at }}</time>{% else %}never{% endif %}</td>
                         <td class="px-4 py-2" data-sort-value="{{ t.revoked_at ? 'revoked' : 'active' }}">
                             {% if t.revoked_at %}
@@ -112,7 +122,7 @@
                         {% endif %}
                     </tr>
                 {% else %}
-                    <tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">No tokens.</td></tr>
+                    <tr><td colspan="7" class="px-4 py-6 text-center text-slate-400">No tokens.</td></tr>
                 {% endfor %}
             </tbody>
         </table>