233 lines
8.2 KiB
PHP
233 lines
8.2 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\Services\Providers\ProviderConnectionStateProjector;
|
|
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,
|
|
ProviderConnectionStateProjector $stateProjector,
|
|
): 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,
|
|
$stateProjector,
|
|
$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, $stateProjector);
|
|
$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,
|
|
ProviderConnectionStateProjector $stateProjector,
|
|
): ProviderConnection {
|
|
DB::transaction(function () use ($connection, $result, $stateProjector): void {
|
|
$connection->forceFill(
|
|
$connection->classificationProjection($result, $stateProjector)
|
|
)->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',
|
|
);
|
|
}
|
|
}
|