feat(firewall): add scoped log administration reads
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
@@ -15,6 +17,8 @@ final class FirewallController extends ControllerAbstract
|
||||
public function __construct(
|
||||
private readonly TenantFirewallRuleService $tenantRules,
|
||||
private readonly SystemFirewallRuleService $systemRules,
|
||||
private readonly TenantFirewallLogService $tenantLogs,
|
||||
private readonly SystemFirewallLogService $systemLogs,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -98,6 +102,62 @@ final class FirewallController extends ControllerAbstract
|
||||
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/logs',
|
||||
name: 'firewall.tenant.logs.list',
|
||||
permissions: [TenantFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function tenantLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
?string $ruleId = null,
|
||||
?string $ruleScope = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
|
||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/logs',
|
||||
name: 'firewall.system.logs.list',
|
||||
permissions: [SystemFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function systemLogs(
|
||||
?string $tenantId = null,
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
?string $ruleId = null,
|
||||
?string $ruleScope = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
|
||||
$tenantId,
|
||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace KTXC\Module;
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
@@ -135,6 +137,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallLogService::PERMISSION_READ => [
|
||||
'label' => 'View Tenant Firewall Logs',
|
||||
'description' => 'View firewall security and audit logs owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
SystemFirewallRuleService::PERMISSION_READ => [
|
||||
'label' => 'View System Firewall Rules',
|
||||
'description' => 'View firewall rules that apply to every tenant',
|
||||
@@ -145,6 +152,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallLogService::PERMISSION_READ => [
|
||||
'label' => 'View System Firewall Logs',
|
||||
'description' => 'View firewall security and audit logs across tenants',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
'system.admin' => [
|
||||
'label' => 'System Administrator',
|
||||
'description' => 'Full system access (superuser)',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
|
||||
final class FirewallLogService
|
||||
{
|
||||
public const MAX_LIMIT = 100;
|
||||
|
||||
private const EVENT_TYPES = [
|
||||
FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||
FirewallLogObject::EVENT_SUSPICIOUS,
|
||||
FirewallLogObject::EVENT_RULE_MATCH,
|
||||
FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
FirewallLogObject::EVENT_RULE_CREATED,
|
||||
FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||
FirewallLogObject::EVENT_RULE_DISABLED,
|
||||
FirewallLogObject::EVENT_RULE_REMOVED,
|
||||
];
|
||||
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
public function tenant(string $tenantId, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
return $this->store->queryTenantLogs(
|
||||
$tenantId,
|
||||
$this->validate($filters, $limit, $offset),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
public function system(?string $tenantId, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
|
||||
throw new \InvalidArgumentException('Invalid tenant filter.');
|
||||
}
|
||||
return $this->store->querySystemLogs(
|
||||
$tenantId,
|
||||
$this->validate($filters, $limit, $offset),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
private function validate(array $filters, int $limit, int $offset): array
|
||||
{
|
||||
if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) {
|
||||
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
||||
}
|
||||
$ipAddress = self::nullableString($filters, 'ipAddress');
|
||||
if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException('Invalid IP address filter.');
|
||||
}
|
||||
$eventType = self::nullableString($filters, 'eventType');
|
||||
if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) {
|
||||
throw new \InvalidArgumentException('Invalid firewall event type filter.');
|
||||
}
|
||||
$result = self::nullableString($filters, 'result');
|
||||
if ($result !== null && !in_array($result, [
|
||||
FirewallLogObject::RESULT_ALLOWED,
|
||||
FirewallLogObject::RESULT_BLOCKED,
|
||||
FirewallLogObject::RESULT_RECORDED,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid firewall result filter.');
|
||||
}
|
||||
$ruleScope = self::nullableString($filters, 'ruleScope');
|
||||
if ($ruleScope !== null && !in_array($ruleScope, [
|
||||
FirewallRuleObject::SCOPE_TENANT,
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid rule scope filter.');
|
||||
}
|
||||
$from = self::date($filters, 'from');
|
||||
$to = self::date($filters, 'to');
|
||||
if ($from !== null && $to !== null && $from > $to) {
|
||||
throw new \InvalidArgumentException('The from date must not be later than the to date.');
|
||||
}
|
||||
|
||||
return [
|
||||
'ipAddress' => $ipAddress,
|
||||
'eventType' => $eventType,
|
||||
'result' => $result,
|
||||
'ruleId' => self::nullableString($filters, 'ruleId'),
|
||||
'ruleScope' => $ruleScope,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
}
|
||||
|
||||
private static function nullableString(array $filters, string $key): ?string
|
||||
{
|
||||
$value = $filters[$key] ?? null;
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($value) || $value === '' || strlen($value) > 255) {
|
||||
throw new \InvalidArgumentException("Invalid {$key} filter.");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function date(array $filters, string $key): ?\DateTimeImmutable
|
||||
{
|
||||
$value = self::nullableString($filters, $key);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new \DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
throw new \InvalidArgumentException("Invalid {$key} date filter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
|
||||
final class SystemFirewallLogService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.system.logs.read';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallLogService $logs,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function query(?string $tenantId, array $filters, int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
||||
}
|
||||
return $this->logs->system($tenantId, $filters, $limit, $offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
|
||||
final class TenantFirewallLogService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.tenant.logs.read';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallLogService $logs,
|
||||
private readonly TenantContextInterface $tenant,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function query(array $filters, int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
||||
}
|
||||
return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -66,6 +66,14 @@ class FirewallStore
|
||||
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_event_type']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_rule']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['timestamp' => -1],
|
||||
['name' => 'logs_global_timeline']
|
||||
),
|
||||
$claims->createIndex(
|
||||
['expiresAt' => 1],
|
||||
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
|
||||
@@ -357,6 +365,62 @@ class FirewallStore
|
||||
// Log Operations
|
||||
// ========================================
|
||||
|
||||
public function queryTenantLogs(
|
||||
string $tenantId,
|
||||
array $filters,
|
||||
int $limit,
|
||||
int $offset
|
||||
): array {
|
||||
return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
public function querySystemLogs(
|
||||
?string $tenantId,
|
||||
array $filters,
|
||||
int $limit,
|
||||
int $offset
|
||||
): array {
|
||||
return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
/** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */
|
||||
private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
$filter = $scopeFilter;
|
||||
foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) {
|
||||
if (($filters[$field] ?? null) !== null) {
|
||||
$filter[$field] = $filters[$field];
|
||||
}
|
||||
}
|
||||
$timestamp = [];
|
||||
if (($filters['from'] ?? null) instanceof \DateTimeInterface) {
|
||||
$timestamp['$gte'] = self::bsonDate($filters['from']);
|
||||
}
|
||||
if (($filters['to'] ?? null) instanceof \DateTimeInterface) {
|
||||
$timestamp['$lte'] = self::bsonDate($filters['to']);
|
||||
}
|
||||
if ($timestamp !== []) {
|
||||
$filter['timestamp'] = $timestamp;
|
||||
}
|
||||
|
||||
$collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
||||
$items = [];
|
||||
foreach ($collection->find($filter, [
|
||||
'sort' => ['timestamp' => -1, '_id' => -1],
|
||||
'limit' => $limit,
|
||||
'skip' => $offset,
|
||||
]) as $entry) {
|
||||
$items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $collection->countDocuments($filter),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a firewall event
|
||||
*/
|
||||
|
||||
@@ -488,6 +488,52 @@ class FirewallStoreTest extends TestCase
|
||||
self::assertSame('tenant-disabled', $disabled['items'][0]->getReason());
|
||||
}
|
||||
|
||||
#[TestDox('Administrative log queries enforce tenant scope and supported filters')]
|
||||
public function testAdministrativeLogQuery(): void
|
||||
{
|
||||
$matching = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setRuleId('rule-123')
|
||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTimestamp(new \DateTimeImmutable('-1 hour'));
|
||||
$otherTenant = (new FirewallLogObject())
|
||||
->setTenantId('tenant-b')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setRuleId('rule-123')
|
||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTimestamp(new \DateTimeImmutable('-1 hour'));
|
||||
$tooOld = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setRuleId('rule-123')
|
||||
->setTimestamp(new \DateTimeImmutable('-3 days'));
|
||||
foreach ([$matching, $otherTenant, $tooOld] as $log) {
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
|
||||
$tenant = $this->store->queryTenantLogs('tenant-a', [
|
||||
'ipAddress' => '203.0.113.10',
|
||||
'eventType' => FirewallLogObject::EVENT_RULE_MATCH,
|
||||
'result' => FirewallLogObject::RESULT_BLOCKED,
|
||||
'ruleId' => 'rule-123',
|
||||
'ruleScope' => FirewallRuleObject::SCOPE_TENANT,
|
||||
'from' => new \DateTimeImmutable('-1 day'),
|
||||
'to' => new \DateTimeImmutable(),
|
||||
], 50, 0);
|
||||
$system = $this->store->querySystemLogs(null, [], 2, 0);
|
||||
|
||||
self::assertSame(1, $tenant['total']);
|
||||
self::assertSame('tenant-a', $tenant['items'][0]->getTenantId());
|
||||
self::assertSame(3, $system['total']);
|
||||
self::assertCount(2, $system['items']);
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
|
||||
@@ -9,7 +9,10 @@ use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Controllers\FirewallController;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallLogService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
@@ -38,7 +41,9 @@ final class FirewallControllerTest extends TestCase
|
||||
);
|
||||
$this->controller = new FirewallController(
|
||||
new TenantFirewallRuleService($manager, $tenant, $identity),
|
||||
new SystemFirewallRuleService($manager, $identity)
|
||||
new SystemFirewallRuleService($manager, $identity),
|
||||
new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity),
|
||||
new SystemFirewallLogService(new FirewallLogService($this->store), $identity)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +81,8 @@ final class FirewallControllerTest extends TestCase
|
||||
'effectivePolicy' => TenantFirewallRuleService::PERMISSION_READ,
|
||||
'systemRules' => SystemFirewallRuleService::PERMISSION_READ,
|
||||
'systemRule' => SystemFirewallRuleService::PERMISSION_READ,
|
||||
'tenantLogs' => TenantFirewallLogService::PERMISSION_READ,
|
||||
'systemLogs' => SystemFirewallLogService::PERMISSION_READ,
|
||||
];
|
||||
|
||||
foreach ($expected as $method => $permission) {
|
||||
|
||||
@@ -10,6 +10,8 @@ use KTXC\Console\Event\EventsDebugCommand;
|
||||
use KTXC\Module\Module;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
@@ -74,5 +76,7 @@ final class CoreModuleTest extends TestCase
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Service\FirewallLogService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class FirewallLogServicesTest extends TestCase
|
||||
{
|
||||
#[TestDox('Tenant log reads derive tenant ownership and validated filters')]
|
||||
public function testTenantQuery(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$tenant = $this->createStub(TenantContextInterface::class);
|
||||
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||
$identity = $this->createStub(IdentityContextInterface::class);
|
||||
$identity->method('hasPermission')->willReturn(true);
|
||||
$store->expects(self::once())
|
||||
->method('queryTenantLogs')
|
||||
->with(
|
||||
'tenant-a',
|
||||
self::callback(static fn(array $filters): bool =>
|
||||
$filters['eventType'] === FirewallLogObject::EVENT_AUTH_FAILURE
|
||||
&& $filters['from'] instanceof \DateTimeImmutable
|
||||
),
|
||||
25,
|
||||
10
|
||||
)
|
||||
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
|
||||
$service = new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity);
|
||||
|
||||
self::assertSame(0, $service->query([
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'from' => '2026-08-01T00:00:00+00:00',
|
||||
], 25, 10)['total']);
|
||||
}
|
||||
|
||||
#[TestDox('System log reads may filter one tenant without changing ownership')]
|
||||
public function testSystemQuery(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$identity = $this->createStub(IdentityContextInterface::class);
|
||||
$identity->method('hasPermission')->willReturn(true);
|
||||
$store->expects(self::once())
|
||||
->method('querySystemLogs')
|
||||
->with('tenant-a', self::isArray(), 50, 0)
|
||||
->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]);
|
||||
$service = new SystemFirewallLogService(new FirewallLogService($store), $identity);
|
||||
|
||||
self::assertSame(0, $service->query('tenant-a', [], 50, 0)['total']);
|
||||
}
|
||||
|
||||
#[TestDox('Log queries reject invalid filters before database access')]
|
||||
public function testValidation(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$store->expects(self::never())->method('queryTenantLogs');
|
||||
$query = new FirewallLogService($store);
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$query->tenant('tenant-a', ['ipAddress' => 'not-an-ip'], 50, 0);
|
||||
}
|
||||
|
||||
#[TestDox('Log boundaries enforce dedicated read permissions')]
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$identity = $this->createStub(IdentityContextInterface::class);
|
||||
$identity->method('hasPermission')->willReturn(false);
|
||||
$tenant = $this->createStub(TenantContextInterface::class);
|
||||
$store->expects(self::never())->method('queryTenantLogs');
|
||||
$store->expects(self::never())->method('querySystemLogs');
|
||||
|
||||
try {
|
||||
(new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity))->query([]);
|
||||
self::fail('Tenant log read should be rejected.');
|
||||
} catch (\RuntimeException $error) {
|
||||
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
|
||||
}
|
||||
|
||||
$this->expectExceptionMessage(SystemFirewallLogService::PERMISSION_READ);
|
||||
(new SystemFirewallLogService(new FirewallLogService($store), $identity))->query(null, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user