|
|
@@ -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());
|
|
|
+ }
|
|
|
+}
|