|
|
@@ -71,7 +71,14 @@ final class ServiceTokenBootstrapTest extends AppTestCase
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- public function testBootstrapWithDifferentTokenInsertsNewRow(): void
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F13. The previous implementation left the old service-kind
|
|
|
+ * row valid forever (the operator was expected to revoke it manually).
|
|
|
+ * Bootstrap now revokes any previously-active service-kind row when a
|
|
|
+ * new value is provisioned, so a leaked old token cannot authenticate
|
|
|
+ * after the next bootstrap of a fresh value.
|
|
|
+ */
|
|
|
+ public function testBootstrapWithDifferentTokenRevokesPreviousAndInsertsNewRow(): void
|
|
|
{
|
|
|
/** @var ServiceTokenBootstrap $boot */
|
|
|
$boot = $this->container->get(ServiceTokenBootstrap::class);
|
|
|
@@ -84,10 +91,169 @@ final class ServiceTokenBootstrapTest extends AppTestCase
|
|
|
$boot->bootstrap($first);
|
|
|
$boot->bootstrap($second);
|
|
|
|
|
|
- // Both rows should now exist; operator must revoke the old one manually.
|
|
|
+ // Both rows are kept (audit + traceability), but only the second is active.
|
|
|
self::assertSame(
|
|
|
2,
|
|
|
(int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
|
|
|
);
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
|
|
|
+ ),
|
|
|
+ 'old service token must be marked revoked after rotation'
|
|
|
+ );
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
|
|
|
+ ),
|
|
|
+ 'exactly one service token must remain active after rotation'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F13. If the operator has accumulated multiple
|
|
|
+ * service-kind rows (e.g. ran rotations on a pre-fix deploy),
|
|
|
+ * a fresh bootstrap revokes ALL previously-valid service-kind rows,
|
|
|
+ * not just the most recent one.
|
|
|
+ */
|
|
|
+ public function testBootstrapRotationRevokesEveryPreviouslyActiveServiceToken(): void
|
|
|
+ {
|
|
|
+ /** @var ServiceTokenBootstrap $boot */
|
|
|
+ $boot = $this->container->get(ServiceTokenBootstrap::class);
|
|
|
+ /** @var TokenIssuer $issuer */
|
|
|
+ $issuer = $this->container->get(TokenIssuer::class);
|
|
|
+
|
|
|
+ // Simulate a pre-fix history with three accumulated active service rows.
|
|
|
+ /** @var \App\Domain\Auth\TokenHasher $hasher */
|
|
|
+ $hasher = $this->container->get(\App\Domain\Auth\TokenHasher::class);
|
|
|
+ foreach (range(1, 3) as $_) {
|
|
|
+ $raw = $issuer->issue(TokenKind::Service);
|
|
|
+ $this->db->insert('api_tokens', [
|
|
|
+ 'token_hash' => $hasher->hash($raw),
|
|
|
+ 'token_prefix' => substr($raw, 0, 8),
|
|
|
+ 'kind' => 'service',
|
|
|
+ 'reporter_id' => null,
|
|
|
+ 'consumer_id' => null,
|
|
|
+ 'role' => null,
|
|
|
+ 'expires_at' => null,
|
|
|
+ 'revoked_at' => null,
|
|
|
+ 'last_used_at' => null,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ self::assertSame(
|
|
|
+ 3,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ $fresh = $issuer->issue(TokenKind::Service);
|
|
|
+ $boot->bootstrap($fresh);
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ 3,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
|
|
|
+ ),
|
|
|
+ 'all three pre-existing service tokens must be revoked'
|
|
|
+ );
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
|
|
|
+ ),
|
|
|
+ 'only the new service token must remain active'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F13. Rotation must surface in the audit log so SOC tooling
|
|
|
+ * can attribute the change-of-state. The `token.revoked` row carries
|
|
|
+ * a structured `reason: rotated_by_bootstrap` so a query can split
|
|
|
+ * automatic-rotation from operator-initiated revoke.
|
|
|
+ */
|
|
|
+ public function testBootstrapRotationEmitsRevokedAndCreatedAuditRows(): void
|
|
|
+ {
|
|
|
+ /** @var ServiceTokenBootstrap $boot */
|
|
|
+ $boot = $this->container->get(ServiceTokenBootstrap::class);
|
|
|
+ /** @var TokenIssuer $issuer */
|
|
|
+ $issuer = $this->container->get(TokenIssuer::class);
|
|
|
+
|
|
|
+ $first = $issuer->issue(TokenKind::Service);
|
|
|
+ $second = $issuer->issue(TokenKind::Service);
|
|
|
+
|
|
|
+ $boot->bootstrap($first);
|
|
|
+ $this->db->executeStatement("DELETE FROM audit_log");
|
|
|
+
|
|
|
+ $boot->bootstrap($second);
|
|
|
+
|
|
|
+ $revokedDetails = $this->db->fetchOne(
|
|
|
+ "SELECT details_json FROM audit_log WHERE action = 'token.revoked' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsString($revokedDetails);
|
|
|
+ $details = json_decode($revokedDetails, true);
|
|
|
+ self::assertIsArray($details);
|
|
|
+ self::assertSame('service', $details['kind']);
|
|
|
+ self::assertSame('rotated_by_bootstrap', $details['reason']);
|
|
|
+
|
|
|
+ $createdRow = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_kind, details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($createdRow);
|
|
|
+ self::assertSame('system', $createdRow['actor_kind']);
|
|
|
+ $createdDetails = json_decode((string) $createdRow['details_json'], true);
|
|
|
+ self::assertIsArray($createdDetails);
|
|
|
+ self::assertSame('service', $createdDetails['kind']);
|
|
|
+ self::assertSame('bootstrap', $createdDetails['source']);
|
|
|
+ self::assertIsArray($createdDetails['rotated_from']);
|
|
|
+ self::assertCount(1, $createdDetails['rotated_from']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F13 corner case. If the operator put the old token back
|
|
|
+ * into env after explicitly revoking it (intentionally or via env-var
|
|
|
+ * rollback), bootstrap must NOT silently re-enable a known-bad hash.
|
|
|
+ * It refuses; the operator must issue a fresh value.
|
|
|
+ */
|
|
|
+ public function testBootstrapRefusesToReEnablePreviouslyRevokedToken(): void
|
|
|
+ {
|
|
|
+ /** @var ServiceTokenBootstrap $boot */
|
|
|
+ $boot = $this->container->get(ServiceTokenBootstrap::class);
|
|
|
+ /** @var TokenIssuer $issuer */
|
|
|
+ $issuer = $this->container->get(TokenIssuer::class);
|
|
|
+ /** @var \App\Domain\Auth\TokenHasher $hasher */
|
|
|
+ $hasher = $this->container->get(\App\Domain\Auth\TokenHasher::class);
|
|
|
+
|
|
|
+ $raw = $issuer->issue(TokenKind::Service);
|
|
|
+ $this->db->insert('api_tokens', [
|
|
|
+ 'token_hash' => $hasher->hash($raw),
|
|
|
+ 'token_prefix' => substr($raw, 0, 8),
|
|
|
+ 'kind' => 'service',
|
|
|
+ 'reporter_id' => null,
|
|
|
+ 'consumer_id' => null,
|
|
|
+ 'role' => null,
|
|
|
+ 'expires_at' => null,
|
|
|
+ 'revoked_at' => '2026-01-01 00:00:00',
|
|
|
+ 'last_used_at' => null,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $boot->bootstrap($raw);
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ 1,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
|
|
|
+ ),
|
|
|
+ 'revoked row stays revoked'
|
|
|
+ );
|
|
|
+ self::assertSame(
|
|
|
+ 0,
|
|
|
+ (int) $this->db->fetchOne(
|
|
|
+ "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
|
|
|
+ ),
|
|
|
+ 'no fresh active row inserted from a revoked-hash env value'
|
|
|
+ );
|
|
|
}
|
|
|
}
|