## Summary - migrate provider connections to the canonical three-dimension state model: lifecycle via `is_enabled`, consent via `consent_status`, and verification via `verification_status` - remove legacy provider status and health badge paths, update admin and system directory surfaces, and align onboarding, consent callback, verification, resolver, and mutation flows with the new model - add the Spec 188 artifact set, schema migrations, guard coverage, and expanded provider-state tests across admin, system, onboarding, verification, and rendering paths ## Verification - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/SystemPanelAuthTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` - integrated browser smoke: validated admin provider list/detail/edit, tenant provider summary, system directory tenant detail, provider-connection search exclusion, and cleaned up the temporary smoke record afterward ## Filament / implementation notes - Livewire v4.0+ compliance: preserved; this change targets Filament v5 on Livewire v4 and does not introduce older APIs - Provider registration location: unchanged; Laravel 11+ panel providers remain registered in `bootstrap/providers.php` - Globally searchable resources: `ProviderConnectionResource` remains intentionally excluded from global search; tenant global search remains enabled and continues to resolve to view pages - Destructive actions: no new destructive action surface was introduced without confirmation or authorization; existing capability checks continue to gate provider mutations - Asset strategy: unchanged; no new Filament assets were added, so deploy behavior for `php artisan filament:assets` remains unchanged - Testing plan covered: system auth, tenant global search, provider lifecycle enable/disable behavior, and provider truth cleanup cutover behavior Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #219
339 lines
12 KiB
PHP
339 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ScanEntraAdminRolesJob;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\StoredReport;
|
|
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
|
|
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
|
|
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRolesReportService
|
|
{
|
|
return new EntraAdminRolesReportService(
|
|
$graphClient,
|
|
new HighPrivilegeRoleCatalog,
|
|
app(MicrosoftGraphOptionsResolver::class),
|
|
);
|
|
}
|
|
|
|
function scanJobRoleDefs(): array
|
|
{
|
|
return [
|
|
[
|
|
'id' => 'def-ga',
|
|
'displayName' => 'Global Administrator',
|
|
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
|
|
'isBuiltIn' => true,
|
|
],
|
|
];
|
|
}
|
|
|
|
function scanJobAssignments(): array
|
|
{
|
|
return [
|
|
[
|
|
'id' => 'assign-1',
|
|
'roleDefinitionId' => 'def-ga',
|
|
'principalId' => 'user-aaa',
|
|
'directoryScopeId' => '/',
|
|
'principal' => [
|
|
'@odata.type' => '#microsoft.graph.user',
|
|
'displayName' => 'Alice Admin',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
function scanJobGraphMock(bool $failDefinitions = false, bool $failAssignments = false): GraphClientInterface
|
|
{
|
|
return new class(scanJobRoleDefs(), scanJobAssignments(), $failDefinitions, $failAssignments) implements GraphClientInterface
|
|
{
|
|
public function __construct(
|
|
private readonly array $roleDefs,
|
|
private readonly array $assignments,
|
|
private readonly bool $failDefs,
|
|
private readonly bool $failAssigns,
|
|
) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
return match ($policyType) {
|
|
'entraRoleDefinitions' => new GraphResponse(
|
|
success: ! $this->failDefs,
|
|
data: $this->failDefs ? [] : $this->roleDefs,
|
|
status: $this->failDefs ? 403 : 200,
|
|
errors: $this->failDefs ? ['Forbidden'] : [],
|
|
),
|
|
'entraRoleAssignments' => new GraphResponse(
|
|
success: ! $this->failAssigns,
|
|
data: $this->failAssigns ? [] : $this->assignments,
|
|
status: $this->failAssigns ? 403 : 200,
|
|
errors: $this->failAssigns ? ['Forbidden'] : [],
|
|
),
|
|
default => new GraphResponse(success: false, status: 404),
|
|
};
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(success: false, status: 501);
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(success: false, status: 501);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(success: false, status: 501);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(success: false, status: 501);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(success: false, status: 501);
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
it('successful run creates OperationRun and records success with counts', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
ensureDefaultProviderConnection($tenant);
|
|
|
|
$mock = scanJobGraphMock();
|
|
|
|
$job = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
|
|
$job->handle(
|
|
buildScanReportService($mock),
|
|
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
|
app(\App\Services\OperationRunService::class),
|
|
);
|
|
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->first();
|
|
|
|
expect($run)->not->toBeNull()
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($run->summary_counts)->toBeArray()
|
|
->and($run->summary_counts['report_created'])->toBe(1)
|
|
->and($run->summary_counts['findings_created'])->toBeGreaterThanOrEqual(1);
|
|
|
|
// Verify report and findings also exist
|
|
$report = StoredReport::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
|
->first();
|
|
|
|
expect($report)->not->toBeNull();
|
|
|
|
$findingsCount = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
|
->count();
|
|
|
|
expect($findingsCount)->toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('skips tenant without active provider connection', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
|
// Deliberately NOT creating a provider connection
|
|
|
|
$job = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
|
|
$job->handle(
|
|
buildScanReportService(scanJobGraphMock()),
|
|
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
|
app(\App\Services\OperationRunService::class),
|
|
);
|
|
|
|
$runCount = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->count();
|
|
|
|
expect($runCount)->toBe(0);
|
|
|
|
$reportCount = StoredReport::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
|
->count();
|
|
|
|
expect($reportCount)->toBe(0);
|
|
});
|
|
|
|
it('skips tenant when the default microsoft provider connection is disabled', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
$connection = ensureDefaultProviderConnection($tenant);
|
|
$connection->forceFill([
|
|
'is_enabled' => false,
|
|
])->save();
|
|
|
|
$job = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
|
|
$job->handle(
|
|
buildScanReportService(scanJobGraphMock()),
|
|
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
|
app(\App\Services\OperationRunService::class),
|
|
);
|
|
|
|
expect(OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->count())->toBe(0);
|
|
});
|
|
|
|
it('skips tenant when the default microsoft provider connection lacks granted consent', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
$connection = ensureDefaultProviderConnection($tenant);
|
|
$connection->forceFill([
|
|
'consent_status' => \App\Support\Providers\ProviderConsentStatus::Required->value,
|
|
])->save();
|
|
|
|
$job = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
|
|
$job->handle(
|
|
buildScanReportService(scanJobGraphMock()),
|
|
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
|
app(\App\Services\OperationRunService::class),
|
|
);
|
|
|
|
expect(OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->count())->toBe(0);
|
|
});
|
|
|
|
it('Graph failure marks OperationRun as failed and re-throws', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
ensureDefaultProviderConnection($tenant);
|
|
|
|
$mock = scanJobGraphMock(failDefinitions: true);
|
|
|
|
$job = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
|
|
$thrown = false;
|
|
|
|
try {
|
|
$job->handle(
|
|
buildScanReportService($mock),
|
|
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
|
app(\App\Services\OperationRunService::class),
|
|
);
|
|
} catch (RuntimeException) {
|
|
$thrown = true;
|
|
}
|
|
|
|
expect($thrown)->toBeTrue();
|
|
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->first();
|
|
|
|
expect($run)->not->toBeNull()
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
|
->and($run->failure_summary)->toBeArray()
|
|
->and($run->failure_summary[0]['code'])->toBe('entra.admin_roles.scan.failed');
|
|
});
|
|
|
|
it('finding generator runs even when report is deduped', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
ensureDefaultProviderConnection($tenant);
|
|
|
|
$mock = scanJobGraphMock();
|
|
|
|
$reportService = buildScanReportService($mock);
|
|
$findingGenerator = new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
|
|
$runService = app(\App\Services\OperationRunService::class);
|
|
|
|
// Run 1: creates report
|
|
$job1 = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
$job1->handle($reportService, $findingGenerator, $runService);
|
|
|
|
$reportCount = StoredReport::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
|
->count();
|
|
expect($reportCount)->toBe(1);
|
|
|
|
// Mark existing OperationRun as completed so a new one can be created
|
|
OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->update(['status' => OperationRunStatus::Completed->value]);
|
|
|
|
// Run 2: report deduped, but findings still processed
|
|
$job2 = new ScanEntraAdminRolesJob(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
);
|
|
$job2->handle($reportService, $findingGenerator, $runService);
|
|
|
|
// Still only 1 report (deduped)
|
|
$reportCount2 = StoredReport::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
|
->count();
|
|
expect($reportCount2)->toBe(1);
|
|
|
|
// But the run completed successfully with deduped count
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'entra.admin_roles.scan')
|
|
->where('outcome', OperationRunOutcome::Succeeded->value)
|
|
->latest()
|
|
->first();
|
|
|
|
expect($latestRun)->not->toBeNull()
|
|
->and($latestRun->summary_counts['report_deduped'])->toBe(1);
|
|
});
|