| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202 |
- <?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());
- }
- }
|