TenantAtlas/apps/platform/app/Console/Commands/ClassifyProviderConnections.php
ahmido 1655cc481e Spec 188: canonical provider connection state cleanup (#219)
## 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
2026-04-10 11:22:56 +00:00

228 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionClassificationResult;
use App\Services\Providers\ProviderConnectionClassifier;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ClassifyProviderConnections extends Command
{
protected $signature = 'tenantpilot:provider-connections:classify
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
{--connection= : Restrict to a single provider connection id}
{--provider=microsoft : Restrict to one provider}
{--chunk=100 : Chunk size for large write runs}
{--write : Persist the classification results}';
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
public function handle(ProviderConnectionClassifier $classifier): int
{
$query = $this->query();
$write = (bool) $this->option('write');
$chunkSize = max(1, (int) $this->option('chunk'));
$candidateCount = (clone $query)->count();
if ($candidateCount === 0) {
$this->info('No provider connections matched the classification scope.');
return self::SUCCESS;
}
$tenantCounts = (clone $query)
->selectRaw('tenant_id, count(*) as aggregate')
->groupBy('tenant_id')
->pluck('aggregate', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all();
$startedTenants = [];
$classifiedCount = 0;
$appliedCount = 0;
$reviewRequiredCount = 0;
$query
->with(['tenant', 'credential'])
->orderBy('id')
->chunkById($chunkSize, function ($connections) use (
$classifier,
$write,
$tenantCounts,
&$startedTenants,
&$classifiedCount,
&$appliedCount,
&$reviewRequiredCount,
): void {
foreach ($connections as $connection) {
$classifiedCount++;
$result = $classifier->classify(
$connection,
source: 'tenantpilot:provider-connections:classify',
);
if ($result->reviewRequired) {
$reviewRequiredCount++;
}
if (! $write) {
continue;
}
$tenant = $connection->tenant;
if (! $tenant instanceof Tenant) {
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
continue;
}
$tenantKey = (int) $tenant->getKey();
if (! array_key_exists($tenantKey, $startedTenants)) {
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
$startedTenants[$tenantKey] = true;
}
$connection = $this->applyClassification($connection, $result);
$this->auditApplied($tenant, $connection, $result);
$appliedCount++;
}
});
if ($write) {
$this->info(sprintf('Applied classifications: %d', $appliedCount));
} else {
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
}
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
return self::SUCCESS;
}
private function query(): Builder
{
$query = ProviderConnection::query()
->where('provider', (string) $this->option('provider'));
$tenantOption = $this->option('tenant');
if (is_string($tenantOption) && trim($tenantOption) !== '') {
$tenant = Tenant::query()
->forTenant(trim($tenantOption))
->firstOrFail();
$query->where('tenant_id', (int) $tenant->getKey());
}
$connectionOption = $this->option('connection');
if (is_numeric($connectionOption)) {
$query->whereKey((int) $connectionOption);
}
return $query;
}
private function applyClassification(
ProviderConnection $connection,
ProviderConnectionClassificationResult $result,
): ProviderConnection {
DB::transaction(function () use ($connection, $result): void {
$connection->forceFill(
$connection->classificationProjection($result)
)->save();
$credential = $connection->credential;
if (! $credential instanceof ProviderCredential) {
return;
}
$updates = [];
if (
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
&& $credential->source === null
) {
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
}
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
}
if ($updates !== []) {
$credential->forceFill($updates)->save();
}
});
return $connection->fresh(['tenant', 'credential']);
}
private function auditStart(Tenant $tenant, int $candidateCount): void
{
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.migration_classification_started',
context: [
'metadata' => [
'source' => 'tenantpilot:provider-connections:classify',
'provider' => 'microsoft',
'candidate_count' => $candidateCount,
'write' => true,
],
],
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
status: 'success',
);
}
private function auditApplied(
Tenant $tenant,
ProviderConnection $connection,
ProviderConnectionClassificationResult $result,
): void {
$effectiveApp = $connection->effectiveAppMetadata();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.migration_classification_applied',
context: [
'metadata' => [
'source' => 'tenantpilot:provider-connections:classify',
'workspace_id' => (int) $connection->workspace_id,
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value,
'migration_review_required' => $connection->migration_review_required,
'legacy_identity_result' => $result->suggestedConnectionType->value,
'effective_app_id' => $effectiveApp['app_id'],
'effective_app_source' => $effectiveApp['source'],
'signals' => $result->signals,
],
],
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
}