feat: standardize platform provider identity
This commit is contained in:
parent
45a804970e
commit
373ad896e0
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -66,6 +66,8 @@ ## Active Technologies
|
||||
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -85,8 +87,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 137-platform-provider-identity: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||
- 136-admin-canonical-tenant: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
|
||||
- 135-canonical-tenant-context-resolution: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
|
||||
- 134-audit-log-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
@ -0,0 +1,232 @@
|
||||
<?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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,8 @@
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
||||
@ -35,7 +36,12 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
@ -48,12 +54,15 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions as SchemaActions;
|
||||
use Filament\Schemas\Components\Callout;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Filament\Schemas\Components\UnorderedList;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Database\QueryException;
|
||||
@ -164,6 +173,7 @@ public function content(Schema $schema): Schema
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Entra Tenant ID (GUID)')
|
||||
->required()
|
||||
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
||||
->rules(['uuid'])
|
||||
->maxLength(255),
|
||||
Select::make('environment')
|
||||
@ -270,17 +280,24 @@ public function content(Schema $schema): Schema
|
||||
->label('Directory (tenant) ID')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Toggle::make('uses_dedicated_override')
|
||||
->label('Dedicated override')
|
||||
->helperText('Explicit exception path for a customer-managed app registration.')
|
||||
->visible(fn (): bool => $this->canManageDedicatedOverride())
|
||||
->live(),
|
||||
TextInput::make('client_id')
|
||||
->label('App (client) ID')
|
||||
->required()
|
||||
->label('Dedicated app (client) ID')
|
||||
->required(fn (Get $get): bool => (bool) $get('uses_dedicated_override'))
|
||||
->maxLength(255)
|
||||
->helperText('If you change the App (client) ID, enter the matching new client secret below.'),
|
||||
->helperText('If you change the dedicated App (client) ID, enter the matching new client secret below.')
|
||||
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
|
||||
TextInput::make('client_secret')
|
||||
->label('New client secret')
|
||||
->label('New dedicated client secret')
|
||||
->password()
|
||||
->revealable(false)
|
||||
->maxLength(255)
|
||||
->helperText('Optional for name-only edits. Required when changing the App (client) ID. The existing secret is never shown.'),
|
||||
->helperText('Required when enabling dedicated mode or changing the dedicated App (client) ID. The existing secret is never shown.')
|
||||
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
|
||||
])
|
||||
->action(function (array $data, Get $get): void {
|
||||
$recordId = $get('provider_connection_id');
|
||||
@ -298,18 +315,37 @@ public function content(Schema $schema): Schema
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->maxLength(255)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||
TextInput::make('new_connection.client_id')
|
||||
->label('Client ID')
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->maxLength(255)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||
TextInput::make('new_connection.client_secret')
|
||||
->label('Client secret')
|
||||
->password()
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->maxLength(255)
|
||||
TextInput::make('new_connection.connection_type')
|
||||
->label('Connection type')
|
||||
->default('Platform connection')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->helperText('Stored encrypted and never shown again.'),
|
||||
->helperText('Managed centrally by platform. Grant admin consent after creating the connection.'),
|
||||
TextInput::make('new_connection.platform_app_id')
|
||||
->label('Platform app ID')
|
||||
->default(fn (): string => $this->platformAppClientId())
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||
Toggle::make('new_connection.uses_dedicated_override')
|
||||
->label('Dedicated override')
|
||||
->helperText('Explicit exception path for a customer-managed app registration.')
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && $this->canManageDedicatedOverride())
|
||||
->live(),
|
||||
TextInput::make('new_connection.client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
|
||||
->maxLength(255)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
|
||||
TextInput::make('new_connection.client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->revealable(false)
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
|
||||
->maxLength(255)
|
||||
->helperText('Required only for the dedicated override path. The secret is never shown again after save.')
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
|
||||
Toggle::make('new_connection.is_default')
|
||||
->label('Make default')
|
||||
->default(true)
|
||||
@ -328,14 +364,13 @@ public function content(Schema $schema): Schema
|
||||
|
||||
$this->createProviderConnection([
|
||||
'display_name' => (string) ($new['display_name'] ?? ''),
|
||||
'connection_type' => (bool) ($new['uses_dedicated_override'] ?? false)
|
||||
? ProviderConnectionType::Dedicated->value
|
||||
: ProviderConnectionType::Platform->value,
|
||||
'client_id' => (string) ($new['client_id'] ?? ''),
|
||||
'client_secret' => (string) ($new['client_secret'] ?? ''),
|
||||
'is_default' => (bool) ($new['is_default'] ?? true),
|
||||
]);
|
||||
|
||||
if (is_array($this->data['new_connection'] ?? null)) {
|
||||
$this->data['new_connection']['client_secret'] = null;
|
||||
}
|
||||
} else {
|
||||
$providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0);
|
||||
|
||||
@ -387,10 +422,10 @@ public function content(Schema $schema): Schema
|
||||
]),
|
||||
])
|
||||
->beforeValidation(function (): void {
|
||||
if (! $this->verificationHasSucceeded()) {
|
||||
if (! $this->verificationCanProceed()) {
|
||||
Notification::make()
|
||||
->title('Verification required')
|
||||
->body('Run verification successfully before continuing.')
|
||||
->body('Complete verification for the selected provider connection before continuing.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -433,18 +468,44 @@ public function content(Schema $schema): Schema
|
||||
}),
|
||||
|
||||
Step::make('Complete')
|
||||
->description('Activate the tenant and finish onboarding.')
|
||||
->description('Review configuration and activate the tenant.')
|
||||
->schema([
|
||||
Section::make('Finish')
|
||||
Section::make('Review & Activate')
|
||||
->description('Review the onboarding summary before activating this tenant.')
|
||||
->schema([
|
||||
Text::make(fn (): string => $this->managedTenant instanceof Tenant
|
||||
? 'Tenant: '.$this->managedTenant->name
|
||||
: 'Tenant: not selected')
|
||||
->badge()
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => 'Verification: '.$this->verificationStatusLabel())
|
||||
->badge()
|
||||
->color(fn (): string => $this->verificationStatusColor()),
|
||||
Section::make('Onboarding summary')
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Tenant')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->completionSummaryTenantLine())
|
||||
->weight(FontWeight::SemiBold),
|
||||
Text::make('Provider connection')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->completionSummaryConnectionSummary())
|
||||
->weight(FontWeight::SemiBold),
|
||||
Text::make('Verification')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->verificationStatusLabel().' — '.$this->completionSummaryVerificationDetail())
|
||||
->badge()
|
||||
->color(fn (): string => $this->verificationStatusColor()),
|
||||
Text::make('Bootstrap')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
||||
->badge()
|
||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||
]),
|
||||
Callout::make('After activation')
|
||||
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
||||
->info()
|
||||
->footer([
|
||||
UnorderedList::make([
|
||||
'Tenant status will be set to Active.',
|
||||
'Backup, inventory, and compliance operations become available.',
|
||||
'The provider connection will be used for all Graph API calls.',
|
||||
]),
|
||||
]),
|
||||
Toggle::make('override_blocked')
|
||||
->label('Override blocked verification')
|
||||
->helperText('Owner-only. Requires a reason and will be recorded in the audit log.')
|
||||
@ -458,8 +519,14 @@ public function content(Schema $schema): Schema
|
||||
->maxLength(500),
|
||||
SchemaActions::make([
|
||||
Action::make('wizardCompleteOnboarding')
|
||||
->label('Complete onboarding')
|
||||
->label('Activate tenant')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Activate tenant')
|
||||
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
|
||||
? sprintf('Are you sure you want to activate "%s"? This will make the tenant operational.', $this->managedTenant->name)
|
||||
: 'Are you sure you want to activate this tenant?')
|
||||
->modalSubmitActionLabel('Yes, activate')
|
||||
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
||||
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
||||
@ -531,11 +598,19 @@ private function initializeWizardData(): void
|
||||
$this->data['notes'] ??= '';
|
||||
$this->data['override_blocked'] ??= false;
|
||||
$this->data['override_reason'] ??= '';
|
||||
$this->data['new_connection'] ??= [];
|
||||
|
||||
if (! array_key_exists('connection_mode', $this->data)) {
|
||||
$this->data['connection_mode'] = 'existing';
|
||||
}
|
||||
|
||||
if (is_array($this->data['new_connection'])) {
|
||||
$this->data['new_connection']['connection_type'] ??= 'Platform connection';
|
||||
$this->data['new_connection']['platform_app_id'] ??= $this->platformAppClientId();
|
||||
$this->data['new_connection']['uses_dedicated_override'] ??= false;
|
||||
$this->data['new_connection']['is_default'] ??= true;
|
||||
}
|
||||
|
||||
if ($this->managedTenant instanceof Tenant) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
|
||||
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
|
||||
@ -606,7 +681,7 @@ private function computeWizardStartStep(): int
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (! $this->verificationHasSucceeded()) {
|
||||
if (! $this->verificationCanProceed()) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@ -655,6 +730,17 @@ private function verificationStatus(): string
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
return match ($this->verificationReportOverall()) {
|
||||
VerificationReportOverall::Blocked->value => 'blocked',
|
||||
VerificationReportOverall::NeedsAttention->value => 'needs_attention',
|
||||
VerificationReportOverall::Ready->value => 'ready',
|
||||
VerificationReportOverall::Running->value => 'in_progress',
|
||||
default => $this->verificationStatusFromRunOutcome($run),
|
||||
};
|
||||
}
|
||||
|
||||
private function verificationStatusFromRunOutcome(OperationRun $run): string
|
||||
{
|
||||
if ($run->outcome === OperationRunOutcome::Blocked->value) {
|
||||
return 'blocked';
|
||||
}
|
||||
@ -696,6 +782,60 @@ private function verificationStatus(): string
|
||||
return 'needs_attention';
|
||||
}
|
||||
|
||||
private function verificationReportOverall(): ?string
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
|
||||
$overall = $summary['overall'] ?? null;
|
||||
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $overall;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}|null
|
||||
*/
|
||||
private function verificationReportCounts(): ?array
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : null;
|
||||
|
||||
if (! is_array($counts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $counts['total'],
|
||||
'pass' => $counts['pass'],
|
||||
'fail' => $counts['fail'],
|
||||
'warn' => $counts['warn'],
|
||||
'skip' => $counts['skip'],
|
||||
'running' => $counts['running'],
|
||||
];
|
||||
}
|
||||
|
||||
private function verificationRunIsActive(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
@ -1269,7 +1409,7 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{display_name: string, client_id: string, client_secret: string, is_default?: bool} $data
|
||||
* @param array{display_name: string, connection_type?: string, client_id?: string, client_secret?: string, is_default?: bool} $data
|
||||
*/
|
||||
public function createProviderConnection(array $data): void
|
||||
{
|
||||
@ -1296,33 +1436,83 @@ public function createProviderConnection(array $data): void
|
||||
}
|
||||
|
||||
$displayName = trim((string) ($data['display_name'] ?? ''));
|
||||
$clientId = (string) ($data['client_id'] ?? '');
|
||||
$clientSecret = (string) ($data['client_secret'] ?? '');
|
||||
$requestedConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
|
||||
$clientId = trim((string) ($data['client_id'] ?? ''));
|
||||
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
|
||||
$makeDefault = (bool) ($data['is_default'] ?? false);
|
||||
$usesDedicatedCredential = $requestedConnectionType === ProviderConnectionType::Dedicated
|
||||
|| $clientId !== ''
|
||||
|| $clientSecret !== '';
|
||||
|
||||
if ($displayName === '') {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
if ($usesDedicatedCredential) {
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||
}
|
||||
|
||||
if ($usesDedicatedCredential && ($clientId === '' || $clientSecret === '')) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$wasExistingConnection = false;
|
||||
$previousConnectionType = null;
|
||||
|
||||
/** @var ProviderConnection $connection */
|
||||
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection {
|
||||
$connection = ProviderConnection::query()->updateOrCreate(
|
||||
[
|
||||
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', (string) $tenant->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
$connection = ProviderConnection::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
],
|
||||
[
|
||||
'display_name' => $displayName,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
]);
|
||||
} else {
|
||||
$wasExistingConnection = true;
|
||||
$previousConnectionType = $connection->connection_type instanceof ProviderConnectionType
|
||||
? $connection->connection_type
|
||||
: ProviderConnectionType::Platform;
|
||||
|
||||
$connection->forceFill([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => $displayName,
|
||||
],
|
||||
);
|
||||
])->save();
|
||||
}
|
||||
|
||||
app(CredentialManager::class)->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
clientSecret: $clientSecret,
|
||||
);
|
||||
if ($usesDedicatedCredential) {
|
||||
$connection = app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
clientSecret: $clientSecret,
|
||||
);
|
||||
}
|
||||
|
||||
if ($makeDefault) {
|
||||
$connection->makeDefault();
|
||||
@ -1331,6 +1521,58 @@ public function createProviderConnection(array $data): void
|
||||
return $connection;
|
||||
});
|
||||
|
||||
$auditLogger = app(AuditLogger::class);
|
||||
$actorId = (int) $user->getKey();
|
||||
$actorEmail = (string) $user->email;
|
||||
$actorName = (string) $user->name;
|
||||
|
||||
if (! $wasExistingConnection) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.created',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
if ($previousConnectionType instanceof ProviderConnectionType && $previousConnectionType !== $connection->connection_type) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'from_connection_type' => $previousConnectionType->value,
|
||||
'to_connection_type' => $connection->connection_type->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
@ -1357,6 +1599,18 @@ public function createProviderConnection(array $data): void
|
||||
->send();
|
||||
}
|
||||
|
||||
private function platformAppClientId(): string
|
||||
{
|
||||
$clientId = trim((string) config('graph.client_id'));
|
||||
|
||||
return $clientId !== '' ? $clientId : 'Platform app not configured';
|
||||
}
|
||||
|
||||
private function canManageDedicatedOverride(): bool
|
||||
{
|
||||
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||
}
|
||||
|
||||
public function startVerification(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -1573,10 +1827,10 @@ public function startBootstrap(array $operationTypes): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $this->verificationHasSucceeded()) {
|
||||
if (! $this->verificationCanProceed()) {
|
||||
Notification::make()
|
||||
->title('Verification required')
|
||||
->body('Run verification successfully before starting bootstrap actions.')
|
||||
->body('Complete verification for the selected provider connection before starting bootstrap actions.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -1814,6 +2068,44 @@ public function verificationSucceeded(): bool
|
||||
return $this->verificationHasSucceeded();
|
||||
}
|
||||
|
||||
private function verificationCanProceed(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->verificationStatus(), ['ready', 'needs_attention'], true);
|
||||
}
|
||||
|
||||
private function verificationIsBlocked(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->verificationStatus() === 'blocked';
|
||||
}
|
||||
|
||||
private function canCompleteOnboarding(): bool
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
@ -1824,7 +2116,7 @@ private function canCompleteOnboarding(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->verificationHasSucceeded()) {
|
||||
if ($this->verificationCanProceed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1832,13 +2124,148 @@ private function canCompleteOnboarding(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->verificationStatus() !== 'blocked') {
|
||||
if (! $this->verificationIsBlocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
private function completionSummaryTenantLine(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$name = $this->managedTenant->name ?? '—';
|
||||
$tenantId = $this->managedTenant->graphTenantId();
|
||||
|
||||
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
|
||||
}
|
||||
|
||||
private function completionSummaryConnectionLabel(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return 'Not configured';
|
||||
}
|
||||
|
||||
$type = $connection->connection_type instanceof ProviderConnectionType
|
||||
? $connection->connection_type
|
||||
: ProviderConnectionType::tryFrom((string) $connection->connection_type);
|
||||
|
||||
return match ($type) {
|
||||
ProviderConnectionType::Platform => 'Platform',
|
||||
ProviderConnectionType::Dedicated => 'Dedicated',
|
||||
default => (string) ($connection->display_name ?? 'Unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
private function completionSummaryConnectionDetail(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$consentStatus = $connection->consent_status instanceof ProviderConsentStatus
|
||||
? $connection->consent_status
|
||||
: ProviderConsentStatus::tryFrom((string) $connection->consent_status);
|
||||
|
||||
$consentLabel = match ($consentStatus) {
|
||||
ProviderConsentStatus::Granted => 'Consent granted',
|
||||
ProviderConsentStatus::Failed => 'Consent failed',
|
||||
ProviderConsentStatus::Required => 'Consent required',
|
||||
ProviderConsentStatus::Revoked => 'Consent revoked',
|
||||
default => 'Consent unknown',
|
||||
};
|
||||
|
||||
$parts = [$connection->display_name ?? '', $consentLabel];
|
||||
|
||||
return implode(' · ', array_filter($parts, static fn (string $v): bool => $v !== ''));
|
||||
}
|
||||
|
||||
private function completionSummaryConnectionSummary(): string
|
||||
{
|
||||
$label = $this->completionSummaryConnectionLabel();
|
||||
$detail = $this->completionSummaryConnectionDetail();
|
||||
|
||||
if ($detail === '') {
|
||||
return $label;
|
||||
}
|
||||
|
||||
return sprintf('%s - %s', $label, $detail);
|
||||
}
|
||||
|
||||
private function completionSummaryVerificationDetail(): string
|
||||
{
|
||||
$counts = $this->verificationReportCounts();
|
||||
|
||||
if ($counts === null) {
|
||||
return 'Not started';
|
||||
}
|
||||
|
||||
return sprintf('%d/%d checks passed', $counts['pass'], $counts['total']);
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapLabel(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return 'Skipped';
|
||||
}
|
||||
|
||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||
$runs = is_array($runs) ? $runs : [];
|
||||
|
||||
if ($runs === []) {
|
||||
return 'Skipped';
|
||||
}
|
||||
|
||||
return 'Started';
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapDetail(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return 'No bootstrap actions selected';
|
||||
}
|
||||
|
||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||
$runs = is_array($runs) ? $runs : [];
|
||||
|
||||
if ($runs === []) {
|
||||
return 'No bootstrap actions selected';
|
||||
}
|
||||
|
||||
return sprintf('%d operation run(s) started', count($runs));
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapSummary(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s - %s',
|
||||
$this->completionSummaryBootstrapLabel(),
|
||||
$this->completionSummaryBootstrapDetail(),
|
||||
);
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapColor(): string
|
||||
{
|
||||
return $this->completionSummaryBootstrapLabel() === 'Started'
|
||||
? 'info'
|
||||
: 'gray';
|
||||
}
|
||||
|
||||
public function completeOnboarding(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -1858,21 +2285,17 @@ public function completeOnboarding(): void
|
||||
}
|
||||
|
||||
$run = $this->verificationRun();
|
||||
$verificationSucceeded = $run instanceof OperationRun
|
||||
&& $run->status === OperationRunStatus::Completed->value
|
||||
&& $run->outcome === OperationRunOutcome::Succeeded->value;
|
||||
$verificationSucceeded = $this->verificationHasSucceeded();
|
||||
$verificationCanProceed = $this->verificationCanProceed();
|
||||
$verificationBlocked = $this->verificationIsBlocked();
|
||||
|
||||
$verificationBlocked = $run instanceof OperationRun
|
||||
&& $run->status === OperationRunStatus::Completed->value
|
||||
&& $run->outcome === OperationRunOutcome::Failed->value;
|
||||
|
||||
if (! $verificationSucceeded) {
|
||||
if (! $verificationCanProceed) {
|
||||
$overrideBlocked = (bool) ($this->data['override_blocked'] ?? false);
|
||||
|
||||
if (! $overrideBlocked) {
|
||||
Notification::make()
|
||||
->title('Verification required')
|
||||
->body('Complete verification successfully before finishing onboarding.')
|
||||
->body('Complete verification for the selected provider connection before finishing onboarding.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -1972,15 +2395,8 @@ private function verificationRun(): ?OperationRun
|
||||
|
||||
private function verificationHasSucceeded(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $run->status === OperationRunStatus::Completed->value
|
||||
&& $run->outcome === OperationRunOutcome::Succeeded->value
|
||||
&& $this->verificationRunMatchesSelectedConnection($run);
|
||||
return $this->verificationCanProceed()
|
||||
&& $this->verificationStatus() === 'ready';
|
||||
}
|
||||
|
||||
private function verificationRunIsStaleForSelectedConnection(): bool
|
||||
@ -2057,7 +2473,7 @@ private function connectionRecentlyUpdated(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{display_name: string, entra_tenant_id: string, client_id: string}
|
||||
* @return array{display_name: string, entra_tenant_id: string, uses_dedicated_override: bool, client_id: string}
|
||||
*/
|
||||
private function inlineEditSelectedConnectionFill(int $providerConnectionId): array
|
||||
{
|
||||
@ -2090,12 +2506,13 @@ private function inlineEditSelectedConnectionFill(int $providerConnectionId): ar
|
||||
return [
|
||||
'display_name' => (string) $connection->display_name,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'uses_dedicated_override' => $connection->connection_type === ProviderConnectionType::Dedicated,
|
||||
'client_id' => (string) ($payload['client_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{display_name?: mixed, client_id?: mixed, client_secret?: mixed} $data
|
||||
* @param array{display_name?: mixed, connection_type?: mixed, uses_dedicated_override?: mixed, client_id?: mixed, client_secret?: mixed} $data
|
||||
*/
|
||||
public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void
|
||||
{
|
||||
@ -2123,8 +2540,13 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
}
|
||||
|
||||
$displayName = trim((string) ($data['display_name'] ?? ''));
|
||||
$explicitConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
|
||||
$requestedDedicatedOverride = (bool) ($data['uses_dedicated_override'] ?? false);
|
||||
$clientId = trim((string) ($data['client_id'] ?? ''));
|
||||
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
|
||||
$existingType = $connection->connection_type instanceof ProviderConnectionType
|
||||
? $connection->connection_type
|
||||
: ProviderConnectionType::Platform;
|
||||
|
||||
if ($displayName === '') {
|
||||
throw ValidationException::withMessages([
|
||||
@ -2132,15 +2554,13 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
]);
|
||||
}
|
||||
|
||||
if ($clientId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'client_id' => 'App (client) ID is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
$existingPayload = $connection->credential?->payload;
|
||||
$existingPayload = is_array($existingPayload) ? $existingPayload : [];
|
||||
$existingClientId = trim((string) ($existingPayload['client_id'] ?? ''));
|
||||
$targetType = $explicitConnectionType
|
||||
?? (($requestedDedicatedOverride || $clientId !== '' || $clientSecret !== '' || $existingType === ProviderConnectionType::Dedicated)
|
||||
? ProviderConnectionType::Dedicated
|
||||
: ProviderConnectionType::Platform);
|
||||
|
||||
$changedFields = [];
|
||||
|
||||
@ -2148,39 +2568,84 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
$changedFields[] = 'display_name';
|
||||
}
|
||||
|
||||
if ($clientId !== $existingClientId) {
|
||||
$changedFields[] = 'client_id';
|
||||
if ($targetType !== $existingType) {
|
||||
$changedFields[] = 'connection_type';
|
||||
}
|
||||
|
||||
if ($clientId !== $existingClientId && $clientSecret === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'client_secret' => 'Enter a new client secret when changing the App (client) ID.',
|
||||
]);
|
||||
if ($targetType === ProviderConnectionType::Dedicated) {
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||
|
||||
if ($clientId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'client_id' => 'Dedicated App (client) ID is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($clientId !== $existingClientId) {
|
||||
$changedFields[] = 'client_id';
|
||||
}
|
||||
|
||||
if (($existingType !== ProviderConnectionType::Dedicated || $clientId !== $existingClientId) && $clientSecret === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'client_secret' => 'Enter a dedicated client secret when enabling dedicated mode or changing the App (client) ID.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($clientSecret !== '') {
|
||||
$changedFields[] = 'client_secret';
|
||||
}
|
||||
}
|
||||
|
||||
if ($clientSecret !== '') {
|
||||
$changedFields[] = 'client_secret';
|
||||
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret): void {
|
||||
DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret, $targetType, $existingType, $existingClientId): void {
|
||||
$connection->forceFill([
|
||||
'display_name' => $displayName,
|
||||
])->save();
|
||||
|
||||
if ($clientSecret !== '') {
|
||||
app(CredentialManager::class)->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
if ($targetType === ProviderConnectionType::Dedicated && $clientSecret !== '') {
|
||||
app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
|
||||
connection: $connection->fresh(),
|
||||
clientId: $clientId,
|
||||
clientSecret: $clientSecret,
|
||||
);
|
||||
} else {
|
||||
app(CredentialManager::class)->updateClientIdPreservingSecret(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
);
|
||||
}
|
||||
|
||||
if ($targetType === ProviderConnectionType::Dedicated && $existingType === ProviderConnectionType::Dedicated && $clientSecret === '' && $clientId !== '' && $clientId === $existingClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
|
||||
app(ProviderConnectionMutationService::class)->revertToPlatform($connection->fresh());
|
||||
}
|
||||
});
|
||||
|
||||
if (in_array('connection_type', $changedFields, true)) {
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $this->managedTenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'from_connection_type' => $existingType->value,
|
||||
'to_connection_type' => $targetType->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: (string) $user->email,
|
||||
actorName: (string) $user->name,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
if ($changedFields !== []) {
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $this->managedTenant,
|
||||
@ -2192,6 +2657,7 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'fields' => $changedFields,
|
||||
'connection_type' => $targetType->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||
],
|
||||
],
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -31,8 +32,10 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -401,6 +404,96 @@ private static function sanitizeErrorMessage(?string $value): ?string
|
||||
return Str::limit($normalized, 120);
|
||||
}
|
||||
|
||||
private static function providerConnectionTypeLabel(?ProviderConnection $record): string
|
||||
{
|
||||
$connectionType = $record?->connection_type;
|
||||
|
||||
if ($connectionType instanceof ProviderConnectionType && $connectionType === ProviderConnectionType::Dedicated) {
|
||||
return 'Dedicated connection';
|
||||
}
|
||||
|
||||
return 'Platform connection';
|
||||
}
|
||||
|
||||
private static function effectiveAppId(?ProviderConnection $record): string
|
||||
{
|
||||
$effectiveAppId = $record?->effectiveAppId();
|
||||
|
||||
return filled($effectiveAppId) ? (string) $effectiveAppId : 'Effective app pending review';
|
||||
}
|
||||
|
||||
private static function credentialSourceLabel(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Managed centrally by platform';
|
||||
}
|
||||
|
||||
$effectiveApp = $record->effectiveAppMetadata();
|
||||
|
||||
return match ($effectiveApp['source'] ?? null) {
|
||||
'dedicated_credential' => 'Dedicated credential',
|
||||
'review_required' => 'Legacy identity review required',
|
||||
default => 'Managed centrally by platform',
|
||||
};
|
||||
}
|
||||
|
||||
private static function migrationReviewLabel(?ProviderConnection $record): string
|
||||
{
|
||||
return $record?->requiresMigrationReview() ? 'Review required' : 'Clear';
|
||||
}
|
||||
|
||||
private static function migrationReviewDescription(?ProviderConnection $record): ?string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$metadata = $record->legacyIdentityMetadata();
|
||||
$source = $metadata['legacy_identity_classification_source'] ?? null;
|
||||
$result = $metadata['legacy_identity_result'] ?? null;
|
||||
|
||||
if (! $record->requiresMigrationReview()) {
|
||||
return filled($source) ? sprintf('Classified via %s as %s.', $source, $result ?? 'platform') : null;
|
||||
}
|
||||
|
||||
$signals = $metadata['legacy_identity_signals'] ?? [];
|
||||
$tenantClientId = $signals['tenant_client_id'] ?? null;
|
||||
$credentialClientId = $signals['credential_client_id'] ?? null;
|
||||
|
||||
if (filled($tenantClientId) && filled($credentialClientId)) {
|
||||
return sprintf('Legacy tenant app %s conflicts with dedicated app %s.', $tenantClientId, $credentialClientId);
|
||||
}
|
||||
|
||||
return 'Legacy app evidence conflicts with the current connection and needs explicit review.';
|
||||
}
|
||||
|
||||
private static function consentStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'required' => 'Required',
|
||||
'granted' => 'Granted',
|
||||
'failed' => 'Failed',
|
||||
'revoked' => 'Revoked',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private static function verificationStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'pending' => 'Pending',
|
||||
'healthy' => 'Healthy',
|
||||
'degraded' => 'Degraded',
|
||||
'blocked' => 'Blocked',
|
||||
'error' => 'Error',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -418,6 +511,15 @@ public static function form(Schema $schema): Schema
|
||||
->maxLength(255)
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->rules(['uuid']),
|
||||
Placeholder::make('connection_type_display')
|
||||
->label('Connection type')
|
||||
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||
Placeholder::make('platform_app_id_display')
|
||||
->label('Effective app ID')
|
||||
->content(fn (?ProviderConnection $record): string => static::effectiveAppId($record)),
|
||||
Placeholder::make('effective_app_source_display')
|
||||
->label('Effective app source')
|
||||
->content(fn (?ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||
Toggle::make('is_default')
|
||||
->label('Default connection')
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
@ -427,6 +529,12 @@ public static function form(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
Placeholder::make('consent_status_display')
|
||||
->label('Consent')
|
||||
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
||||
Placeholder::make('verification_status_display')
|
||||
->label('Verification')
|
||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||
TextInput::make('status')
|
||||
->label('Status')
|
||||
->disabled()
|
||||
@ -435,17 +543,69 @@ public static function form(Schema $schema): Schema
|
||||
->label('Health')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Placeholder::make('migration_review_status_display')
|
||||
->label('Migration review')
|
||||
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Connection')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('display_name')
|
||||
->label('Display name'),
|
||||
Infolists\Components\TextEntry::make('provider')
|
||||
->label('Provider'),
|
||||
Infolists\Components\TextEntry::make('entra_tenant_id')
|
||||
->label('Entra tenant ID')
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('connection_type')
|
||||
->label('Connection type')
|
||||
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||
? 'Dedicated connection'
|
||||
: 'Platform connection'),
|
||||
Infolists\Components\TextEntry::make('effective_app_id')
|
||||
->label('Effective app ID')
|
||||
->state(fn (ProviderConnection $record): string => static::effectiveAppId($record))
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('effective_app_source')
|
||||
->label('Effective app source')
|
||||
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('consent_status')
|
||||
->label('Consent')
|
||||
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
|
||||
Infolists\Components\TextEntry::make('verification_status')
|
||||
->label('Verification')
|
||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->label('Status'),
|
||||
Infolists\Components\TextEntry::make('health_status')
|
||||
->label('Health'),
|
||||
Infolists\Components\TextEntry::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$query->with('tenant');
|
||||
$query->with(['tenant', 'credential']);
|
||||
|
||||
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||
|
||||
@ -488,6 +648,13 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||
Tables\Columns\TextColumn::make('connection_type')
|
||||
->label('Connection type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||
? 'Dedicated'
|
||||
: 'Platform')
|
||||
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
@ -502,6 +669,11 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||
Tables\Columns\TextColumn::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
|
||||
->color(fn (bool $state): string => $state ? 'warning' : 'success'),
|
||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
@ -920,31 +1092,32 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
Actions\Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Client secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
@ -957,12 +1130,16 @@ public static function table(Table $table): Table
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.credentials_updated',
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.resource_table',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -974,12 +1151,138 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Credentials updated')
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (ProviderConnection $record): string {
|
||||
$payload = $record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $record->credential()->exists())
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($record);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.resource_table',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
|
||||
@ -6,6 +6,11 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
@ -25,12 +30,31 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
'display_name' => $data['display_name'],
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'is_default' => false,
|
||||
];
|
||||
}
|
||||
@ -57,6 +81,7 @@ protected function afterCreate(): void
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'connection_type' => $record->connection_type->value,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
|
||||
@ -11,13 +11,14 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -310,32 +311,33 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Client secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
@ -348,12 +350,16 @@ protected function getHeaderActions(): array
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.credentials_updated',
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.edit_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -365,13 +371,143 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Credentials updated')
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Stores a replacement dedicated client secret and refreshes dedicated identity state.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (ProviderConnection $record): string {
|
||||
$payload = $record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $record->credential()->exists())
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Reverts the connection to the platform-managed identity and removes any dedicated credential.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($record);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.edit_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
|
||||
@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
||||
|
||||
public function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
return 'No provider connections found';
|
||||
return 'No Microsoft connections found';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
|
||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateActions(): array
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewProviderConnection extends ViewRecord
|
||||
@ -15,6 +23,24 @@ class ViewProviderConnection extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('grant_admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(function (): ?string {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||
: null;
|
||||
})
|
||||
->visible(function (): bool {
|
||||
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -23,6 +49,201 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (): bool => $this->record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $this->record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $this->record->getKey(),
|
||||
'provider' => $this->record->provider,
|
||||
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.view_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (): string {
|
||||
$payload = $this->record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $this->record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $this->record->credential()->exists())
|
||||
->action(function (ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($this->record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($this->record);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $this->record->getKey(),
|
||||
'provider' => $this->record->provider,
|
||||
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.view_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('Manage dedicated override')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Services\Intune\RbacOnboardingService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
@ -454,7 +455,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
@ -936,7 +937,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Integration')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('admin_consent_url')
|
||||
->label('Admin consent URL')
|
||||
->label('Grant admin consent URL')
|
||||
->state(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (?string $state) => filled($state))
|
||||
->copyable()
|
||||
@ -1231,36 +1232,6 @@ public static function rbacAction(): Actions\Action
|
||||
}
|
||||
|
||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$tenantId = $tenant->graphTenantId();
|
||||
$clientId = $tenant->app_client_id;
|
||||
|
||||
if (! is_string($clientId) || trim($clientId) === '') {
|
||||
$clientId = static::resolveProviderClientIdForConsent($tenant);
|
||||
}
|
||||
$redirectUri = route('admin.consent.callback');
|
||||
$state = sprintf('tenantpilot|%s', $tenant->id);
|
||||
|
||||
if (! $tenantId || ! $clientId || ! $redirectUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Admin consent should use `.default` so the tenant consents to the app's configured
|
||||
// application permissions. Keeping the URL short also avoids edge cases where a long
|
||||
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
|
||||
$scopes = 'https://graph.microsoft.com/.default';
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'state' => $state,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => $scopes,
|
||||
]);
|
||||
|
||||
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
|
||||
}
|
||||
|
||||
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
|
||||
{
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -1273,21 +1244,11 @@ private static function resolveProviderClientIdForConsent(Tenant $tenant): ?stri
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $connection->credential?->payload;
|
||||
|
||||
if (! is_array($payload)) {
|
||||
try {
|
||||
return app(AdminConsentUrlFactory::class)->make($connection, sprintf('tenantpilot|%s', $tenant->id));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = $payload['client_id'] ?? null;
|
||||
|
||||
if (! is_string($clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim($clientId);
|
||||
|
||||
return $clientId !== '' ? $clientId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1340,10 +1301,21 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
|
||||
public static function entraUrl(Tenant $tenant): ?string
|
||||
{
|
||||
if ($tenant->app_client_id) {
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$effectiveAppId = $connection instanceof ProviderConnection
|
||||
? $connection->effectiveAppId()
|
||||
: null;
|
||||
|
||||
if (filled($effectiveAppId)) {
|
||||
return sprintf(
|
||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
|
||||
$tenant->app_client_id
|
||||
$effectiveAppId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ protected function getHeaderActions(): array
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
||||
|
||||
@ -5,7 +5,11 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
||||
@ -51,25 +55,47 @@ public function __invoke(
|
||||
error: $error,
|
||||
);
|
||||
|
||||
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
||||
$auditMetadata = [
|
||||
'source' => 'admin.consent.callback',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'status' => $status,
|
||||
'state' => $state,
|
||||
'error' => $error,
|
||||
'consent' => $consentGranted,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'consent_status' => $connection->consent_status->value,
|
||||
'verification_status' => $connection->verification_status->value,
|
||||
];
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.consent.callback',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'status' => $status,
|
||||
'state' => $state,
|
||||
'error' => $error,
|
||||
'consent' => $consentGranted,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'metadata' => $auditMetadata,
|
||||
],
|
||||
status: $status === 'ok' ? 'success' : 'error',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: $legacyStatus,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.consent_result',
|
||||
context: [
|
||||
'metadata' => $auditMetadata,
|
||||
],
|
||||
status: $legacyStatus,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
return view('admin-consent-callback', [
|
||||
'tenant' => $tenant,
|
||||
'connection' => $connection,
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
'consentGranted' => $consentGranted,
|
||||
@ -112,16 +138,19 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
->where('is_default', true)
|
||||
->exists();
|
||||
|
||||
$connectionStatus = match ($status) {
|
||||
'ok' => 'connected',
|
||||
'error' => 'error',
|
||||
'consent_denied' => 'needs_consent',
|
||||
default => 'needs_consent',
|
||||
$consentStatus = match ($status) {
|
||||
'ok' => ProviderConsentStatus::Granted,
|
||||
'error' => ProviderConsentStatus::Failed,
|
||||
default => ProviderConsentStatus::Required,
|
||||
};
|
||||
|
||||
$verificationStatus = ProviderVerificationStatus::Unknown;
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
$reasonCode = match ($status) {
|
||||
'ok' => null,
|
||||
'consent_denied' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'error' => ProviderReasonCodes::ProviderAuthFailed,
|
||||
default => ProviderReasonCodes::ProviderConsentMissing,
|
||||
};
|
||||
@ -135,19 +164,30 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'status' => $connectionStatus,
|
||||
'health_status' => $connectionStatus === 'connected' ? 'unknown' : 'degraded',
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => $consentStatus->value,
|
||||
'consent_granted_at' => $status === 'ok' ? now() : null,
|
||||
'consent_last_checked_at' => now(),
|
||||
'consent_error_code' => $reasonCode,
|
||||
'consent_error_message' => $error,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => $reasonCode,
|
||||
'last_error_message' => $error,
|
||||
'is_default' => $hasDefault ? false : true,
|
||||
],
|
||||
);
|
||||
|
||||
$connection->credential()->delete();
|
||||
|
||||
if (! $hasDefault && ! $connection->is_default) {
|
||||
$connection->makeDefault();
|
||||
}
|
||||
|
||||
return $connection;
|
||||
return $connection->fresh();
|
||||
}
|
||||
|
||||
private function parseState(?string $state): ?string
|
||||
|
||||
@ -2,21 +2,30 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
||||
|
||||
class TenantOnboardingController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$clientId = config('graph.client_id');
|
||||
$redirectUri = route('admin.consent.callback');
|
||||
$targetTenant = $request->string('tenant')->toString() ?: config('graph.tenant_id', 'organizations');
|
||||
$tenantSegment = $targetTenant ?: 'organizations';
|
||||
|
||||
abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured');
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
AdminConsentUrlFactory $consentUrlFactory,
|
||||
AuditLogger $auditLogger,
|
||||
): RedirectResponse {
|
||||
$tenantIdentifier = $request->string('tenant')->toString();
|
||||
abort_if($tenantIdentifier === '', ResponseAlias::HTTP_NOT_FOUND);
|
||||
|
||||
$state = Str::uuid()->toString();
|
||||
$request->session()->put('tenant_onboard_state', $state);
|
||||
@ -27,13 +36,108 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$request->session()->put('tenant_onboard_workspace_id', (int) $workspaceId);
|
||||
}
|
||||
|
||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'state' => $state,
|
||||
]);
|
||||
$tenant = $this->resolveTenant($tenantIdentifier, is_numeric($workspaceId) ? (int) $workspaceId : null);
|
||||
$connection = $this->upsertPlatformConnection($tenant);
|
||||
$url = $consentUrlFactory->make($connection, $state);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.consent_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'admin.consent.start',
|
||||
'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,
|
||||
'effective_client_id' => trim((string) config('graph.client_id')),
|
||||
'state' => $state,
|
||||
],
|
||||
],
|
||||
actorId: auth()->id(),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
private function resolveTenant(string $tenantIdentifier, ?int $workspaceId): Tenant
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->where(function ($query) use ($tenantIdentifier): void {
|
||||
$query->where('tenant_id', $tenantIdentifier)
|
||||
->orWhere('external_id', $tenantIdentifier);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant->workspace_id === null && $workspaceId !== null) {
|
||||
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
abort_if($workspaceId === null, ResponseAlias::HTTP_FORBIDDEN, 'Missing workspace context');
|
||||
|
||||
return Tenant::create([
|
||||
'tenant_id' => $tenantIdentifier,
|
||||
'name' => 'New Tenant',
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
|
||||
{
|
||||
$hasDefault = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->exists();
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) ($tenant->graphTenantId() ?? $tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'is_default' => $hasDefault ? false : true,
|
||||
],
|
||||
);
|
||||
|
||||
$connection->credential()->delete();
|
||||
|
||||
if (! $hasDefault && ! $connection->is_default) {
|
||||
$connection->makeDefault();
|
||||
}
|
||||
|
||||
return $connection->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,15 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\AuditLogger as TenantAuditLogger;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Services\Providers\ProviderIdentityResolution;
|
||||
use App\Services\Providers\ProviderIdentityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -74,8 +78,10 @@ public function handle(
|
||||
}
|
||||
|
||||
$result = $healthCheck->check($connection);
|
||||
|
||||
$this->applyHealthResult($connection, $result);
|
||||
$previousConsentStatus = $connection->consent_status?->value ?? $connection->consent_status;
|
||||
$previousVerificationStatus = $connection->verification_status?->value ?? $connection->verification_status;
|
||||
$stateTransition = $this->applyHealthResult($connection, $result);
|
||||
$identity = app(ProviderIdentityResolver::class)->resolve($connection);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
return;
|
||||
@ -133,14 +139,14 @@ public function handle(
|
||||
if (! $result->healthy) {
|
||||
$inventory = [
|
||||
'fresh' => false,
|
||||
'reason_code' => $result->reasonCode ?? 'dependency_unreachable',
|
||||
'reason_code' => $stateTransition['effective_reason_code'] ?? $result->reasonCode ?? 'dependency_unreachable',
|
||||
'message' => 'Provider connection check failed; permissions were not refreshed during this run.',
|
||||
];
|
||||
} elseif ($graphOptions === null) {
|
||||
$inventory = [
|
||||
'fresh' => false,
|
||||
'reason_code' => 'provider_credential_missing',
|
||||
'message' => 'Provider credentials were unavailable; observed permissions inventory was not refreshed during this run.',
|
||||
'reason_code' => $identity->effectiveReasonCode(),
|
||||
'message' => 'Provider identity could not be resolved; observed permissions inventory was not refreshed during this run.',
|
||||
];
|
||||
} else {
|
||||
$liveCheck = $permissionComparison['live_check'] ?? null;
|
||||
@ -181,7 +187,7 @@ public function handle(
|
||||
'status' => $result->healthy ? 'pass' : 'fail',
|
||||
'severity' => $result->healthy ? 'info' : 'critical',
|
||||
'blocking' => ! $result->healthy,
|
||||
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||
'reason_code' => $result->healthy ? 'ok' : ($stateTransition['effective_reason_code'] ?? $result->reasonCode ?? 'unknown_error'),
|
||||
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||
'evidence' => array_values(array_filter([
|
||||
[
|
||||
@ -205,9 +211,11 @@ public function handle(
|
||||
? []
|
||||
: app(ProviderNextStepsRegistry::class)->forReason(
|
||||
$tenant,
|
||||
is_string($result->reasonCode) && $result->reasonCode !== ''
|
||||
? $result->reasonCode
|
||||
: 'unknown_error',
|
||||
is_string($stateTransition['effective_reason_code'] ?? null) && $stateTransition['effective_reason_code'] !== ''
|
||||
? (string) $stateTransition['effective_reason_code']
|
||||
: (is_string($result->reasonCode) && $result->reasonCode !== ''
|
||||
? $result->reasonCode
|
||||
: 'unknown_error'),
|
||||
$connection,
|
||||
),
|
||||
],
|
||||
@ -234,14 +242,27 @@ public function handle(
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$this->logVerificationResult(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
connection: $connection,
|
||||
run: $run,
|
||||
identity: $identity,
|
||||
outcomeStatus: 'success',
|
||||
reasonCode: null,
|
||||
previousConsentStatus: is_string($previousConsentStatus) ? $previousConsentStatus : null,
|
||||
previousVerificationStatus: is_string($previousVerificationStatus) ? $previousVerificationStatus : null,
|
||||
);
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$reasonCode = is_string($result->reasonCode) && $result->reasonCode !== ''
|
||||
? $result->reasonCode
|
||||
: 'unknown_error';
|
||||
$reasonCode = is_string($stateTransition['effective_reason_code'] ?? null) && $stateTransition['effective_reason_code'] !== ''
|
||||
? (string) $stateTransition['effective_reason_code']
|
||||
: (is_string($result->reasonCode) && $result->reasonCode !== ''
|
||||
? $result->reasonCode
|
||||
: 'unknown_error');
|
||||
|
||||
$nextSteps = app(ProviderNextStepsRegistry::class)->forReason(
|
||||
$tenant,
|
||||
@ -249,7 +270,11 @@ public function handle(
|
||||
$connection,
|
||||
);
|
||||
|
||||
if ($reasonCode === ProviderReasonCodes::ProviderConsentMissing) {
|
||||
if (in_array($reasonCode, [
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
], true)) {
|
||||
$run = $runs->finalizeBlockedRun(
|
||||
$this->operationRun,
|
||||
reasonCode: $reasonCode,
|
||||
@ -257,6 +282,27 @@ public function handle(
|
||||
message: $result->message ?? 'Admin consent is required before verification can proceed.',
|
||||
);
|
||||
|
||||
$this->logVerificationResult(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
connection: $connection,
|
||||
run: $run,
|
||||
identity: $identity,
|
||||
outcomeStatus: 'failed',
|
||||
reasonCode: $reasonCode,
|
||||
previousConsentStatus: is_string($previousConsentStatus) ? $previousConsentStatus : null,
|
||||
previousVerificationStatus: is_string($previousVerificationStatus) ? $previousVerificationStatus : null,
|
||||
);
|
||||
if (($stateTransition['consent_revoked_detected'] ?? false) === true) {
|
||||
$this->logConsentRevocation(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
connection: $connection,
|
||||
run: $run,
|
||||
previousConsentStatus: is_string($previousConsentStatus) ? $previousConsentStatus : null,
|
||||
detectedReasonCode: $result->reasonCode,
|
||||
);
|
||||
}
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
@ -273,6 +319,27 @@ public function handle(
|
||||
]],
|
||||
);
|
||||
|
||||
$this->logVerificationResult(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
connection: $connection,
|
||||
run: $run,
|
||||
identity: $identity,
|
||||
outcomeStatus: 'failed',
|
||||
reasonCode: $reasonCode,
|
||||
previousConsentStatus: is_string($previousConsentStatus) ? $previousConsentStatus : null,
|
||||
previousVerificationStatus: is_string($previousVerificationStatus) ? $previousVerificationStatus : null,
|
||||
);
|
||||
if (($stateTransition['consent_revoked_detected'] ?? false) === true) {
|
||||
$this->logConsentRevocation(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
connection: $connection,
|
||||
run: $run,
|
||||
previousConsentStatus: is_string($previousConsentStatus) ? $previousConsentStatus : null,
|
||||
detectedReasonCode: $result->reasonCode,
|
||||
);
|
||||
}
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
}
|
||||
|
||||
@ -296,6 +363,7 @@ private function updateRunTargetScope(OperationRun $run, ProviderConnection $con
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
||||
$targetScope['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||
|
||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
||||
@ -306,15 +374,30 @@ private function updateRunTargetScope(OperationRun $run, ProviderConnection $con
|
||||
$run->update(['context' => $context]);
|
||||
}
|
||||
|
||||
private function applyHealthResult(ProviderConnection $connection, HealthResult $result): void
|
||||
/**
|
||||
* @return array{effective_reason_code: ?string, consent_revoked_detected: bool}
|
||||
*/
|
||||
private function applyHealthResult(ProviderConnection $connection, HealthResult $result): array
|
||||
{
|
||||
$projected = app(ProviderConnectionStateProjector::class)->projectVerificationOutcome($connection, $result);
|
||||
|
||||
$connection->update([
|
||||
'status' => $result->status,
|
||||
'health_status' => $result->healthStatus,
|
||||
'consent_status' => $projected['consent_status'],
|
||||
'verification_status' => $projected['verification_status'],
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
'last_health_check_at' => now(),
|
||||
'last_error_reason_code' => $result->healthy ? null : $result->reasonCode,
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
'last_error_reason_code' => $projected['last_error_reason_code'],
|
||||
'last_error_message' => $projected['last_error_message'],
|
||||
'consent_last_checked_at' => now(),
|
||||
'consent_error_code' => $projected['consent_error_code'],
|
||||
'consent_error_message' => $projected['consent_error_message'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'effective_reason_code' => $projected['last_error_reason_code'],
|
||||
'consent_revoked_detected' => $projected['consent_revoked_detected'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -346,4 +429,76 @@ private function logVerificationCompletion(Tenant $tenant, User $actor, Operatio
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private function logVerificationResult(
|
||||
Tenant $tenant,
|
||||
User $actor,
|
||||
ProviderConnection $connection,
|
||||
OperationRun $run,
|
||||
ProviderIdentityResolution $identity,
|
||||
string $outcomeStatus,
|
||||
?string $reasonCode,
|
||||
?string $previousConsentStatus,
|
||||
?string $previousVerificationStatus,
|
||||
): void {
|
||||
app(TenantAuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.verification_result',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'connection_type' => $connection->connection_type?->value ?? $connection->connection_type,
|
||||
'consent_status' => $connection->consent_status?->value ?? $connection->consent_status,
|
||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
'status' => $connection->status,
|
||||
'health_status' => $connection->health_status,
|
||||
'reason_code' => $reasonCode,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'previous_consent_status' => $previousConsentStatus,
|
||||
'previous_verification_status' => $previousVerificationStatus,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: $outcomeStatus,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private function logConsentRevocation(
|
||||
Tenant $tenant,
|
||||
User $actor,
|
||||
ProviderConnection $connection,
|
||||
OperationRun $run,
|
||||
?string $previousConsentStatus,
|
||||
?string $detectedReasonCode,
|
||||
): void {
|
||||
app(TenantAuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.consent_revoked',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'connection_type' => $connection->connection_type?->value ?? $connection->connection_type,
|
||||
'previous_consent_status' => $previousConsentStatus,
|
||||
'consent_status' => $connection->consent_status?->value ?? $connection->consent_status,
|
||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||
'detected_reason_code' => $detectedReasonCode,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'failed',
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,15 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProviderConnection extends Model
|
||||
@ -16,6 +21,13 @@ class ProviderConnection extends Model
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'connection_type' => ProviderConnectionType::class,
|
||||
'consent_status' => ProviderConsentStatus::class,
|
||||
'consent_granted_at' => 'datetime',
|
||||
'consent_last_checked_at' => 'datetime',
|
||||
'verification_status' => ProviderVerificationStatus::class,
|
||||
'migration_review_required' => 'boolean',
|
||||
'migration_reviewed_at' => 'datetime',
|
||||
'scopes_granted' => 'array',
|
||||
'metadata' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
@ -53,4 +65,169 @@ public function makeDefault(): void
|
||||
|
||||
$this->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* legacy_identity_classification_source: ?string,
|
||||
* legacy_identity_review_required: bool,
|
||||
* legacy_identity_result: ?string,
|
||||
* legacy_identity_signals: array<string, mixed>,
|
||||
* legacy_identity_classified_at: ?string,
|
||||
* effective_app: array{app_id: ?string, source: string}
|
||||
* }
|
||||
*/
|
||||
public function legacyIdentityMetadata(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
$effectiveApp = Arr::get($metadata, 'effective_app');
|
||||
|
||||
return [
|
||||
'legacy_identity_classification_source' => is_string($metadata['legacy_identity_classification_source'] ?? null)
|
||||
? $metadata['legacy_identity_classification_source']
|
||||
: null,
|
||||
'legacy_identity_review_required' => (bool) ($metadata['legacy_identity_review_required'] ?? $this->migration_review_required),
|
||||
'legacy_identity_result' => is_string($metadata['legacy_identity_result'] ?? null)
|
||||
? $metadata['legacy_identity_result']
|
||||
: null,
|
||||
'legacy_identity_signals' => is_array($metadata['legacy_identity_signals'] ?? null)
|
||||
? $metadata['legacy_identity_signals']
|
||||
: [],
|
||||
'legacy_identity_classified_at' => is_string($metadata['legacy_identity_classified_at'] ?? null)
|
||||
? $metadata['legacy_identity_classified_at']
|
||||
: null,
|
||||
'effective_app' => [
|
||||
'app_id' => is_array($effectiveApp) && filled($effectiveApp['app_id'] ?? null)
|
||||
? (string) $effectiveApp['app_id']
|
||||
: null,
|
||||
'source' => is_array($effectiveApp) && filled($effectiveApp['source'] ?? null)
|
||||
? (string) $effectiveApp['source']
|
||||
: $this->defaultEffectiveAppSource(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{app_id: ?string, source: string}
|
||||
*/
|
||||
public function effectiveAppMetadata(): array
|
||||
{
|
||||
$metadata = $this->legacyIdentityMetadata();
|
||||
$effectiveApp = $metadata['effective_app'];
|
||||
|
||||
if ($effectiveApp['app_id'] !== null || $effectiveApp['source'] === 'review_required') {
|
||||
return $effectiveApp;
|
||||
}
|
||||
|
||||
if ($this->connection_type === ProviderConnectionType::Dedicated) {
|
||||
$payload = $this->credential?->payload;
|
||||
$clientId = is_array($payload) ? trim((string) ($payload['client_id'] ?? '')) : '';
|
||||
|
||||
return [
|
||||
'app_id' => $clientId !== '' ? $clientId : null,
|
||||
'source' => 'dedicated_credential',
|
||||
];
|
||||
}
|
||||
|
||||
$platformClientId = trim((string) config('graph.client_id'));
|
||||
|
||||
return [
|
||||
'app_id' => $platformClientId !== '' ? $platformClientId : null,
|
||||
'source' => 'platform_config',
|
||||
];
|
||||
}
|
||||
|
||||
public function effectiveAppId(): ?string
|
||||
{
|
||||
return $this->effectiveAppMetadata()['app_id'];
|
||||
}
|
||||
|
||||
public function requiresMigrationReview(): bool
|
||||
{
|
||||
return $this->legacyIdentityMetadata()['legacy_identity_review_required'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function classificationProjection(
|
||||
\App\Services\Providers\ProviderConnectionClassificationResult $result,
|
||||
\App\Services\Providers\ProviderConnectionStateProjector $stateProjector,
|
||||
): array {
|
||||
$metadata = array_merge(
|
||||
is_array($this->metadata) ? $this->metadata : [],
|
||||
$result->metadata(),
|
||||
['legacy_identity_classified_at' => now()->toJSON()],
|
||||
);
|
||||
|
||||
$projection = [
|
||||
'connection_type' => $result->suggestedConnectionType->value,
|
||||
'migration_review_required' => $result->reviewRequired,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
|
||||
if ($result->reviewRequired) {
|
||||
$statusProjection = $stateProjector->project(
|
||||
connectionType: $result->suggestedConnectionType,
|
||||
consentStatus: $this->consent_status,
|
||||
verificationStatus: ProviderVerificationStatus::Blocked,
|
||||
currentStatus: is_string($this->status) ? $this->status : null,
|
||||
);
|
||||
|
||||
return $projection + [
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'status' => $statusProjection['status'],
|
||||
'health_status' => $statusProjection['health_status'],
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||
'last_error_message' => 'Legacy provider connection requires explicit migration review.',
|
||||
'migration_reviewed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$currentVerificationStatus = $this->verification_status;
|
||||
$currentReasonCode = is_string($this->last_error_reason_code) ? $this->last_error_reason_code : null;
|
||||
|
||||
if (
|
||||
($this->migration_review_required || $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired)
|
||||
&& $currentVerificationStatus === ProviderVerificationStatus::Blocked
|
||||
) {
|
||||
$currentVerificationStatus = ProviderVerificationStatus::Unknown;
|
||||
}
|
||||
|
||||
if (
|
||||
$this->migration_review_required
|
||||
|| $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
|
||||
) {
|
||||
$statusProjection = $stateProjector->project(
|
||||
connectionType: $result->suggestedConnectionType,
|
||||
consentStatus: $this->consent_status,
|
||||
verificationStatus: $currentVerificationStatus,
|
||||
currentStatus: is_string($this->status) ? $this->status : null,
|
||||
);
|
||||
|
||||
return $projection + [
|
||||
'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus
|
||||
? $currentVerificationStatus->value
|
||||
: $currentVerificationStatus,
|
||||
'status' => $statusProjection['status'],
|
||||
'health_status' => $statusProjection['health_status'],
|
||||
'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
|
||||
? null
|
||||
: $currentReasonCode,
|
||||
'last_error_message' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
|
||||
? null
|
||||
: $this->last_error_message,
|
||||
];
|
||||
}
|
||||
|
||||
return $projection;
|
||||
}
|
||||
|
||||
private function defaultEffectiveAppSource(): string
|
||||
{
|
||||
if ($this->connection_type === ProviderConnectionType::Dedicated) {
|
||||
return 'dedicated_credential';
|
||||
}
|
||||
|
||||
return 'platform_config';
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -17,7 +19,11 @@ class ProviderCredential extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'credential_kind' => ProviderCredentialKind::class,
|
||||
'source' => ProviderCredentialSource::class,
|
||||
'payload' => 'encrypted:array',
|
||||
'last_rotated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function providerConnection(): BelongsTo
|
||||
|
||||
@ -285,6 +285,29 @@ public function graphTenantId(): ?string
|
||||
return $this->tenant_id ?? $this->external_id;
|
||||
}
|
||||
|
||||
public function legacyProviderClientId(): ?string
|
||||
{
|
||||
$clientId = trim((string) $this->app_client_id);
|
||||
|
||||
return $clientId !== '' ? $clientId : null;
|
||||
}
|
||||
|
||||
public function hasLegacyProviderSecret(): bool
|
||||
{
|
||||
return trim((string) $this->app_client_secret) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{client_id: ?string, has_secret: bool}
|
||||
*/
|
||||
public function legacyProviderIdentity(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => $this->legacyProviderClientId(),
|
||||
'has_secret' => $this->hasLegacyProviderSecret(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Runtime provider calls must resolve ProviderConnection + ProviderGateway.
|
||||
*
|
||||
|
||||
@ -30,6 +30,7 @@ public function created(ProviderCredential $credential): void
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
action: 'provider_connection.credentials_created',
|
||||
credential: $credential,
|
||||
changedFields: ['type', 'client_id', 'client_secret'],
|
||||
);
|
||||
}
|
||||
@ -62,10 +63,34 @@ public function updated(ProviderCredential $credential): void
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
action: $action,
|
||||
credential: $credential,
|
||||
changedFields: $changedFields,
|
||||
);
|
||||
}
|
||||
|
||||
public function deleted(ProviderCredential $credential): void
|
||||
{
|
||||
$connection = $this->resolveConnection($credential);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $connection->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->audit(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
action: 'provider_connection.credentials_deleted',
|
||||
credential: $credential,
|
||||
changedFields: ['client_id', 'client_secret'],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveConnection(ProviderCredential $credential): ?ProviderConnection
|
||||
{
|
||||
$credential->loadMissing('providerConnection.tenant');
|
||||
@ -114,22 +139,34 @@ private function audit(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
string $action,
|
||||
ProviderCredential $credential,
|
||||
array $changedFields,
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
$source = $credential->source;
|
||||
$kind = $credential->credential_kind;
|
||||
$connectionType = $connection->connection_type;
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'credential_type' => (string) $connection->credential?->type,
|
||||
'connection_type' => is_string($connectionType)
|
||||
? $connectionType
|
||||
: $connectionType?->value,
|
||||
'credential_type' => (string) $credential->type,
|
||||
'credential_kind' => is_string($kind) ? $kind : $kind?->value,
|
||||
'credential_source' => is_string($source) ? $source : $source?->value,
|
||||
'last_rotated_at' => $credential->last_rotated_at?->toJSON(),
|
||||
'expires_at' => $credential->expires_at?->toJSON(),
|
||||
'changed_fields' => $changedFields,
|
||||
'redacted_fields' => ['client_secret'],
|
||||
],
|
||||
|
||||
@ -168,6 +168,38 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
||||
return true;
|
||||
}
|
||||
|
||||
public function manageDedicated(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$baseAccess = $this->update($user, $connection);
|
||||
|
||||
if ($baseAccess !== true) {
|
||||
return $baseAccess;
|
||||
}
|
||||
|
||||
$tenant = $this->tenantForConnection($connection);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_MANAGE_DEDICATED, $tenant);
|
||||
}
|
||||
|
||||
public function changeConnectionType(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
return $this->manageDedicated($user, $connection);
|
||||
}
|
||||
|
||||
public function manageDedicatedCredential(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
return $this->manageDedicated($user, $connection);
|
||||
}
|
||||
|
||||
public function deleteDedicatedCredential(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
return $this->manageDedicated($user, $connection);
|
||||
}
|
||||
|
||||
private function currentWorkspace(User $user): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
@ -40,6 +40,7 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
Capabilities::PROVIDER_MANAGE,
|
||||
Capabilities::PROVIDER_MANAGE_DEDICATED,
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
@ -27,6 +27,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
|
||||
37
app/Services/Providers/AdminConsentUrlFactory.php
Normal file
37
app/Services/Providers/AdminConsentUrlFactory.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use RuntimeException;
|
||||
|
||||
final class AdminConsentUrlFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
) {}
|
||||
|
||||
public function make(ProviderConnection $connection, string $state): string
|
||||
{
|
||||
$normalizedState = trim($state);
|
||||
|
||||
if ($normalizedState === '') {
|
||||
throw new RuntimeException('Consent state is required.');
|
||||
}
|
||||
|
||||
$resolution = $this->identityResolver->resolve($connection);
|
||||
|
||||
if (! $resolution->resolved || $resolution->effectiveClientId === null || $resolution->redirectUri === null) {
|
||||
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.');
|
||||
}
|
||||
|
||||
$tenantSegment = trim($resolution->tenantContext) !== '' ? trim($resolution->tenantContext) : 'organizations';
|
||||
|
||||
return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
||||
'client_id' => $resolution->effectiveClientId,
|
||||
'redirect_uri' => $resolution->redirectUri,
|
||||
'scope' => (string) config('graph.scope', 'https://graph.microsoft.com/.default'),
|
||||
'state' => $normalizedState,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
@ -14,6 +17,10 @@ final class CredentialManager
|
||||
*/
|
||||
public function getClientCredentials(ProviderConnection $connection): array
|
||||
{
|
||||
if ($connection->connection_type !== ProviderConnectionType::Dedicated) {
|
||||
throw new InvalidArgumentException('Dedicated provider credentials are only available for dedicated connections.');
|
||||
}
|
||||
|
||||
$credential = $connection->credential;
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
@ -53,6 +60,7 @@ public function upsertClientSecretCredential(
|
||||
ProviderConnection $connection,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
ProviderCredentialSource $source = ProviderCredentialSource::DedicatedManual,
|
||||
): ProviderCredential {
|
||||
$clientId = trim($clientId);
|
||||
$clientSecret = trim($clientSecret);
|
||||
@ -61,12 +69,23 @@ public function upsertClientSecretCredential(
|
||||
throw new InvalidArgumentException('client_id and client_secret are required.');
|
||||
}
|
||||
|
||||
$existing = $connection->credential;
|
||||
$existingPayload = $existing instanceof ProviderCredential && is_array($existing->payload)
|
||||
? $existing->payload
|
||||
: [];
|
||||
$secretChanged = ! $existing instanceof ProviderCredential
|
||||
|| trim((string) ($existingPayload['client_secret'] ?? '')) !== $clientSecret;
|
||||
|
||||
return ProviderCredential::query()->updateOrCreate(
|
||||
[
|
||||
'provider_connection_id' => $connection->getKey(),
|
||||
],
|
||||
[
|
||||
'type' => 'client_secret',
|
||||
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
|
||||
'source' => $source->value,
|
||||
'last_rotated_at' => $secretChanged ? now() : $existing?->last_rotated_at,
|
||||
'expires_at' => $existing?->expires_at,
|
||||
'payload' => [
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
@ -84,11 +103,15 @@ public function updateClientIdPreservingSecret(ProviderConnection $connection, s
|
||||
}
|
||||
|
||||
$existing = $this->getClientCredentials($connection);
|
||||
$source = $connection->credential?->source instanceof ProviderCredentialSource
|
||||
? $connection->credential->source
|
||||
: ProviderCredentialSource::DedicatedManual;
|
||||
|
||||
return $this->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
clientSecret: (string) $existing['client_secret'],
|
||||
source: $source,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Services/Providers/PlatformProviderIdentityResolver.php
Normal file
58
app/Services/Providers/PlatformProviderIdentityResolver.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class PlatformProviderIdentityResolver
|
||||
{
|
||||
public function resolve(string $tenantContext): ProviderIdentityResolution
|
||||
{
|
||||
$targetTenant = trim($tenantContext);
|
||||
$clientId = trim((string) config('graph.client_id'));
|
||||
$clientSecret = trim((string) config('graph.client_secret'));
|
||||
$authorityTenant = trim((string) config('graph.tenant_id', 'organizations'));
|
||||
$redirectUri = trim((string) route('admin.consent.callback'));
|
||||
|
||||
if ($targetTenant === '') {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
tenantContext: 'organizations',
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
message: 'Provider connection is missing target tenant scope.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($clientId === '') {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
tenantContext: $targetTenant,
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
||||
message: 'Platform app identity is not configured.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($clientSecret === '' || $redirectUri === '') {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
tenantContext: $targetTenant,
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
message: 'Platform app identity is incomplete.',
|
||||
);
|
||||
}
|
||||
|
||||
return ProviderIdentityResolution::resolved(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
tenantContext: $targetTenant,
|
||||
effectiveClientId: $clientId,
|
||||
credentialSource: 'platform_config',
|
||||
clientSecret: $clientSecret,
|
||||
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
||||
redirectUri: $redirectUri,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
|
||||
final class ProviderConnectionClassificationResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $signals
|
||||
* @param array{app_id: ?string, source: string} $effectiveApp
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $providerConnectionId,
|
||||
public readonly ProviderConnectionType $suggestedConnectionType,
|
||||
public readonly bool $reviewRequired,
|
||||
public readonly array $signals,
|
||||
public readonly array $effectiveApp,
|
||||
public readonly string $source,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function metadata(): array
|
||||
{
|
||||
return [
|
||||
'legacy_identity_classification_source' => $this->source,
|
||||
'legacy_identity_review_required' => $this->reviewRequired,
|
||||
'legacy_identity_signals' => $this->signals,
|
||||
'legacy_identity_result' => $this->suggestedConnectionType->value,
|
||||
'effective_app' => $this->effectiveApp,
|
||||
'suggested_connection_type' => $this->suggestedConnectionType->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
118
app/Services/Providers/ProviderConnectionClassifier.php
Normal file
118
app/Services/Providers/ProviderConnectionClassifier.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
|
||||
final class ProviderConnectionClassifier
|
||||
{
|
||||
public function classify(
|
||||
ProviderConnection $connection,
|
||||
string $source = 'migration_scan',
|
||||
): ProviderConnectionClassificationResult {
|
||||
$connection->loadMissing(['credential', 'tenant']);
|
||||
|
||||
$tenant = $connection->tenant;
|
||||
$credential = $connection->credential;
|
||||
$legacyIdentity = $tenant instanceof Tenant ? $tenant->legacyProviderIdentity() : ['client_id' => null, 'has_secret' => false];
|
||||
$tenantClientId = trim((string) ($legacyIdentity['client_id'] ?? ''));
|
||||
$credentialClientId = $this->credentialClientId($credential);
|
||||
$currentConnectionType = $connection->connection_type instanceof ProviderConnectionType
|
||||
? $connection->connection_type->value
|
||||
: (is_string($connection->connection_type) ? $connection->connection_type : null);
|
||||
$hasLegacyTenantIdentity = $tenantClientId !== '' || (bool) ($legacyIdentity['has_secret'] ?? false);
|
||||
$hasDedicatedCredential = $credential instanceof ProviderCredential;
|
||||
|
||||
$suggestedConnectionType = ProviderConnectionType::Platform;
|
||||
$reviewRequired = false;
|
||||
|
||||
if ($hasDedicatedCredential && ! $hasLegacyTenantIdentity) {
|
||||
$suggestedConnectionType = ProviderConnectionType::Dedicated;
|
||||
} elseif ($hasDedicatedCredential && $hasLegacyTenantIdentity) {
|
||||
$suggestedConnectionType = ProviderConnectionType::Dedicated;
|
||||
$reviewRequired = $tenantClientId === '' || $credentialClientId === '' || $tenantClientId !== $credentialClientId;
|
||||
} elseif (! $hasDedicatedCredential && $hasLegacyTenantIdentity) {
|
||||
$suggestedConnectionType = ProviderConnectionType::Platform;
|
||||
$reviewRequired = true;
|
||||
}
|
||||
|
||||
return new ProviderConnectionClassificationResult(
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
suggestedConnectionType: $suggestedConnectionType,
|
||||
reviewRequired: $reviewRequired,
|
||||
signals: [
|
||||
'has_dedicated_credential' => $hasDedicatedCredential,
|
||||
'credential_client_id' => $credentialClientId !== '' ? $credentialClientId : null,
|
||||
'has_legacy_tenant_identity' => $hasLegacyTenantIdentity,
|
||||
'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null,
|
||||
'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false),
|
||||
'current_connection_type' => $currentConnectionType,
|
||||
'consent_status' => $this->enumValue($connection->consent_status),
|
||||
'verification_status' => $this->enumValue($connection->verification_status),
|
||||
'status' => is_string($connection->status) ? $connection->status : null,
|
||||
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
|
||||
],
|
||||
effectiveApp: $this->effectiveAppMetadata(
|
||||
suggestedConnectionType: $suggestedConnectionType,
|
||||
reviewRequired: $reviewRequired,
|
||||
credentialClientId: $credentialClientId,
|
||||
),
|
||||
source: $source,
|
||||
);
|
||||
}
|
||||
|
||||
private function credentialClientId(?ProviderCredential $credential): string
|
||||
{
|
||||
if (! $credential instanceof ProviderCredential || ! is_array($credential->payload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) ($credential->payload['client_id'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{app_id: ?string, source: string}
|
||||
*/
|
||||
private function effectiveAppMetadata(
|
||||
ProviderConnectionType $suggestedConnectionType,
|
||||
bool $reviewRequired,
|
||||
string $credentialClientId,
|
||||
): array {
|
||||
if ($reviewRequired) {
|
||||
return [
|
||||
'app_id' => null,
|
||||
'source' => 'review_required',
|
||||
];
|
||||
}
|
||||
|
||||
if ($suggestedConnectionType === ProviderConnectionType::Dedicated) {
|
||||
return [
|
||||
'app_id' => $credentialClientId !== '' ? $credentialClientId : null,
|
||||
'source' => 'dedicated_credential',
|
||||
];
|
||||
}
|
||||
|
||||
$platformClientId = trim((string) config('graph.client_id'));
|
||||
|
||||
return [
|
||||
'app_id' => $platformClientId !== '' ? $platformClientId : null,
|
||||
'source' => 'platform_config',
|
||||
];
|
||||
}
|
||||
|
||||
private function enumValue(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \BackedEnum) {
|
||||
return $value->value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
183
app/Services/Providers/ProviderConnectionMutationService.php
Normal file
183
app/Services/Providers/ProviderConnectionMutationService.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ProviderConnectionMutationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CredentialManager $credentials,
|
||||
private readonly ProviderConnectionStateProjector $stateProjector,
|
||||
) {}
|
||||
|
||||
public function enableDedicatedOverride(
|
||||
ProviderConnection $connection,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
): ProviderConnection {
|
||||
$clientId = trim($clientId);
|
||||
$clientSecret = trim($clientSecret);
|
||||
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {
|
||||
$connection->loadMissing('credential');
|
||||
|
||||
$credential = $connection->credential;
|
||||
$payload = $credential instanceof ProviderCredential && is_array($credential->payload)
|
||||
? $credential->payload
|
||||
: [];
|
||||
|
||||
$previousClientId = trim((string) ($payload['client_id'] ?? ''));
|
||||
$needsConsentReset = $connection->connection_type !== ProviderConnectionType::Dedicated
|
||||
|| $previousClientId === ''
|
||||
|| $previousClientId !== $clientId;
|
||||
|
||||
$consentStatus = $needsConsentReset
|
||||
? ProviderConsentStatus::Required
|
||||
: $this->normalizeConsentStatus($connection->consent_status);
|
||||
$verificationStatus = ProviderVerificationStatus::Unknown;
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => $needsConsentReset
|
||||
? ProviderReasonCodes::ProviderConsentMissing
|
||||
: null,
|
||||
'last_error_message' => null,
|
||||
'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []),
|
||||
'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at,
|
||||
'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at,
|
||||
'consent_error_code' => $needsConsentReset ? null : $connection->consent_error_code,
|
||||
'consent_error_message' => $needsConsentReset ? null : $connection->consent_error_message,
|
||||
]))->save();
|
||||
|
||||
$this->credentials->upsertClientSecretCredential(
|
||||
connection: $connection->fresh(),
|
||||
clientId: $clientId,
|
||||
clientSecret: $clientSecret,
|
||||
);
|
||||
|
||||
return $connection->fresh(['credential']);
|
||||
});
|
||||
}
|
||||
|
||||
public function revertToPlatform(ProviderConnection $connection): ProviderConnection
|
||||
{
|
||||
return DB::transaction(function () use ($connection): ProviderConnection {
|
||||
$connection->loadMissing('credential');
|
||||
|
||||
if ($connection->credential instanceof ProviderCredential) {
|
||||
$connection->credential->delete();
|
||||
}
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'scopes_granted' => [],
|
||||
]))->save();
|
||||
|
||||
return $connection->fresh(['credential']);
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteDedicatedCredential(ProviderConnection $connection): ProviderConnection
|
||||
{
|
||||
return DB::transaction(function () use ($connection): ProviderConnection {
|
||||
$connection->loadMissing('credential');
|
||||
|
||||
if ($connection->credential instanceof ProviderCredential) {
|
||||
$connection->credential->delete();
|
||||
}
|
||||
|
||||
$consentStatus = $this->normalizeConsentStatus($connection->consent_status);
|
||||
$verificationStatus = ProviderVerificationStatus::Blocked;
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'consent_status' => $consentStatus->value,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
'last_error_message' => 'Dedicated credential is missing.',
|
||||
]))->save();
|
||||
|
||||
return $connection->fresh(['credential']);
|
||||
});
|
||||
}
|
||||
|
||||
private function projectConnectionState(
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionType $connectionType,
|
||||
ProviderConsentStatus $consentStatus,
|
||||
ProviderVerificationStatus $verificationStatus,
|
||||
): array {
|
||||
$projected = $this->stateProjector->project(
|
||||
connectionType: $connectionType,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
|
||||
return [
|
||||
'consent_status' => $consentStatus->value,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConsentStatus(
|
||||
ProviderConsentStatus|string|null $consentStatus,
|
||||
): ProviderConsentStatus {
|
||||
if ($consentStatus instanceof ProviderConsentStatus) {
|
||||
return $consentStatus;
|
||||
}
|
||||
|
||||
if (is_string($consentStatus)) {
|
||||
return ProviderConsentStatus::tryFrom(trim($consentStatus)) ?? ProviderConsentStatus::Required;
|
||||
}
|
||||
|
||||
return ProviderConsentStatus::Required;
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,16 @@
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class ProviderConnectionResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
) {}
|
||||
|
||||
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
|
||||
{
|
||||
$defaults = ProviderConnection::query()
|
||||
@ -59,15 +63,6 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
||||
);
|
||||
}
|
||||
|
||||
if ((string) $connection->status === 'needs_consent') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
'Provider connection requires admin consent before use.',
|
||||
'ext.connection_needs_consent',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
@ -77,48 +72,62 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
||||
);
|
||||
}
|
||||
|
||||
$credential = $connection->credential()->first();
|
||||
$consentBlocker = $this->consentBlocker($connection);
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
if ($consentBlocker instanceof ProviderConnectionResolution) {
|
||||
return $consentBlocker;
|
||||
}
|
||||
|
||||
$identity = $this->identityResolver->resolve($connection);
|
||||
|
||||
if (! $identity->resolved) {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderCredentialMissing,
|
||||
'Provider connection is missing credentials.',
|
||||
$identity->effectiveReasonCode(),
|
||||
$identity->message,
|
||||
connection: $connection,
|
||||
);
|
||||
}
|
||||
|
||||
if ($credential->type !== 'client_secret') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
'Provider credential type is invalid.',
|
||||
'ext.invalid_credential_type',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $credential->payload;
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
'Provider credential payload is invalid.',
|
||||
'ext.invalid_credential_payload',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
$clientId = trim((string) ($payload['client_id'] ?? ''));
|
||||
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
|
||||
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
'Provider credential payload is missing required fields.',
|
||||
'ext.missing_credential_fields',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
return ProviderConnectionResolution::resolved($connection);
|
||||
}
|
||||
|
||||
private function consentBlocker(ProviderConnection $connection): ?ProviderConnectionResolution
|
||||
{
|
||||
$consentStatus = $connection->consent_status;
|
||||
|
||||
if ($consentStatus instanceof ProviderConsentStatus) {
|
||||
return match ($consentStatus) {
|
||||
ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
'Provider connection requires admin consent before use.',
|
||||
'ext.connection_needs_consent',
|
||||
$connection,
|
||||
),
|
||||
ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
'Provider connection consent failed. Retry admin consent before use.',
|
||||
'ext.connection_consent_failed',
|
||||
$connection,
|
||||
),
|
||||
ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
'Provider connection consent was revoked. Grant admin consent again before use.',
|
||||
'ext.connection_consent_revoked',
|
||||
$connection,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ((string) $connection->status === 'needs_consent') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
'Provider connection requires admin consent before use.',
|
||||
'ext.connection_needs_consent',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
243
app/Services/Providers/ProviderConnectionStateProjector.php
Normal file
243
app/Services/Providers/ProviderConnectionStateProjector.php
Normal file
@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
|
||||
final class ProviderConnectionStateProjector
|
||||
{
|
||||
/**
|
||||
* @return array{status: string, health_status: string}
|
||||
*/
|
||||
public function projectForConnection(ProviderConnection $connection): array
|
||||
{
|
||||
return $this->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $connection->consent_status,
|
||||
verificationStatus: $connection->verification_status,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, health_status: string}
|
||||
*/
|
||||
public function project(
|
||||
ProviderConnectionType|string|null $connectionType,
|
||||
ProviderConsentStatus|string|null $consentStatus,
|
||||
ProviderVerificationStatus|string|null $verificationStatus,
|
||||
?string $currentStatus = null,
|
||||
): array {
|
||||
$resolvedConnectionType = $this->normalizeConnectionType($connectionType) ?? ProviderConnectionType::Platform;
|
||||
$resolvedConsentStatus = $this->normalizeConsentStatus($consentStatus) ?? ProviderConsentStatus::Unknown;
|
||||
$resolvedVerificationStatus = $this->normalizeVerificationStatus($verificationStatus) ?? ProviderVerificationStatus::Unknown;
|
||||
|
||||
$status = $currentStatus === 'disabled'
|
||||
? 'disabled'
|
||||
: $this->projectStatus($resolvedConnectionType, $resolvedConsentStatus, $resolvedVerificationStatus);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'health_status' => $this->projectHealthStatus($resolvedVerificationStatus),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* consent_status: ProviderConsentStatus,
|
||||
* verification_status: ProviderVerificationStatus,
|
||||
* status: string,
|
||||
* health_status: string,
|
||||
* last_error_reason_code: ?string,
|
||||
* last_error_message: ?string,
|
||||
* consent_error_code: ?string,
|
||||
* consent_error_message: ?string,
|
||||
* consent_revoked_detected: bool
|
||||
* }
|
||||
*/
|
||||
public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array
|
||||
{
|
||||
$currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status)
|
||||
?? (((string) $connection->status === 'needs_consent') ? ProviderConsentStatus::Required : ProviderConsentStatus::Unknown);
|
||||
|
||||
$effectiveReasonCode = $result->healthy
|
||||
? null
|
||||
: $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode);
|
||||
|
||||
$consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy);
|
||||
$verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result->healthy, $result->healthStatus);
|
||||
|
||||
$projected = $this->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
|
||||
$consentErrorCode = in_array($consentStatus, [
|
||||
ProviderConsentStatus::Required,
|
||||
ProviderConsentStatus::Failed,
|
||||
ProviderConsentStatus::Revoked,
|
||||
], true) ? $effectiveReasonCode : null;
|
||||
|
||||
return [
|
||||
'consent_status' => $consentStatus,
|
||||
'verification_status' => $verificationStatus,
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
'last_error_reason_code' => $effectiveReasonCode,
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
'consent_error_code' => $consentErrorCode,
|
||||
'consent_error_message' => $consentErrorCode === null || $result->healthy ? null : $result->message,
|
||||
'consent_revoked_detected' => $currentConsentStatus === ProviderConsentStatus::Granted
|
||||
&& $effectiveReasonCode === ProviderReasonCodes::ProviderConsentRevoked,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConnectionType(ProviderConnectionType|string|null $connectionType): ?ProviderConnectionType
|
||||
{
|
||||
if ($connectionType instanceof ProviderConnectionType) {
|
||||
return $connectionType;
|
||||
}
|
||||
|
||||
if (! is_string($connectionType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionType::tryFrom(trim($connectionType));
|
||||
}
|
||||
|
||||
private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus
|
||||
{
|
||||
if ($consentStatus instanceof ProviderConsentStatus) {
|
||||
return $consentStatus;
|
||||
}
|
||||
|
||||
if (! is_string($consentStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConsentStatus::tryFrom(trim($consentStatus));
|
||||
}
|
||||
|
||||
private function normalizeVerificationStatus(
|
||||
ProviderVerificationStatus|string|null $verificationStatus,
|
||||
): ?ProviderVerificationStatus {
|
||||
if ($verificationStatus instanceof ProviderVerificationStatus) {
|
||||
return $verificationStatus;
|
||||
}
|
||||
|
||||
if (! is_string($verificationStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderVerificationStatus::tryFrom(trim($verificationStatus));
|
||||
}
|
||||
|
||||
private function projectStatus(
|
||||
ProviderConnectionType $connectionType,
|
||||
ProviderConsentStatus $consentStatus,
|
||||
ProviderVerificationStatus $verificationStatus,
|
||||
): string {
|
||||
if ($connectionType === ProviderConnectionType::Dedicated && $verificationStatus === ProviderVerificationStatus::Blocked) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($consentStatus === ProviderConsentStatus::Failed) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($consentStatus !== ProviderConsentStatus::Granted) {
|
||||
return 'needs_consent';
|
||||
}
|
||||
|
||||
return match ($verificationStatus) {
|
||||
ProviderVerificationStatus::Blocked,
|
||||
ProviderVerificationStatus::Error => 'error',
|
||||
default => 'connected',
|
||||
};
|
||||
}
|
||||
|
||||
private function projectHealthStatus(ProviderVerificationStatus $verificationStatus): string
|
||||
{
|
||||
return match ($verificationStatus) {
|
||||
ProviderVerificationStatus::Healthy => 'ok',
|
||||
ProviderVerificationStatus::Degraded => 'degraded',
|
||||
ProviderVerificationStatus::Blocked,
|
||||
ProviderVerificationStatus::Error => 'down',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private function effectiveReasonCodeForVerification(
|
||||
ProviderConsentStatus $currentConsentStatus,
|
||||
?string $reasonCode,
|
||||
): ?string {
|
||||
if (! is_string($reasonCode) || $reasonCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
$currentConsentStatus === ProviderConsentStatus::Granted
|
||||
&& $reasonCode === ProviderReasonCodes::ProviderConsentMissing
|
||||
) {
|
||||
return ProviderReasonCodes::ProviderConsentRevoked;
|
||||
}
|
||||
|
||||
return $reasonCode;
|
||||
}
|
||||
|
||||
private function consentStatusAfterVerification(
|
||||
ProviderConsentStatus $currentConsentStatus,
|
||||
?string $reasonCode,
|
||||
bool $healthy,
|
||||
): ProviderConsentStatus {
|
||||
if ($healthy) {
|
||||
return ProviderConsentStatus::Granted;
|
||||
}
|
||||
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderConsentMissing => ProviderConsentStatus::Required,
|
||||
ProviderReasonCodes::ProviderConsentFailed => ProviderConsentStatus::Failed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked => ProviderConsentStatus::Revoked,
|
||||
default => $currentConsentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private function verificationStatusAfterVerification(
|
||||
?string $reasonCode,
|
||||
bool $healthy,
|
||||
string $healthStatus,
|
||||
): ProviderVerificationStatus {
|
||||
if ($healthy) {
|
||||
return ProviderVerificationStatus::Healthy;
|
||||
}
|
||||
|
||||
if ($healthStatus === 'degraded' || $reasonCode === ProviderReasonCodes::RateLimited) {
|
||||
return ProviderVerificationStatus::Degraded;
|
||||
}
|
||||
|
||||
if (in_array($reasonCode, [
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
ProviderReasonCodes::PlatformIdentityMissing,
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid,
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||
ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||
ProviderReasonCodes::TenantTargetMismatch,
|
||||
], true)) {
|
||||
return ProviderVerificationStatus::Blocked;
|
||||
}
|
||||
|
||||
return ProviderVerificationStatus::Error;
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,12 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class ProviderGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClientInterface $graph,
|
||||
private readonly CredentialManager $credentials,
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
) {}
|
||||
|
||||
public function getOrganization(ProviderConnection $connection): GraphResponse
|
||||
@ -54,13 +53,6 @@ public function request(ProviderConnection $connection, string $method, string $
|
||||
*/
|
||||
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
|
||||
{
|
||||
$clientCredentials = $this->credentials->getClientCredentials($connection);
|
||||
|
||||
return array_merge([
|
||||
'tenant' => $connection->entra_tenant_id,
|
||||
'client_id' => $clientCredentials['client_id'],
|
||||
'client_secret' => $clientCredentials['client_secret'],
|
||||
'client_request_id' => (string) Str::uuid(),
|
||||
], $overrides);
|
||||
return $this->identityResolver->resolve($connection)->graphOptions($overrides);
|
||||
}
|
||||
}
|
||||
|
||||
91
app/Services/Providers/ProviderIdentityResolution.php
Normal file
91
app/Services/Providers/ProviderIdentityResolution.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
final class ProviderIdentityResolution
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $resolved,
|
||||
public readonly ProviderConnectionType $connectionType,
|
||||
public readonly string $tenantContext,
|
||||
public readonly ?string $effectiveClientId,
|
||||
public readonly string $credentialSource,
|
||||
public readonly ?string $clientSecret,
|
||||
public readonly ?string $authorityTenant,
|
||||
public readonly ?string $redirectUri,
|
||||
public readonly ?string $reasonCode,
|
||||
public readonly ?string $message,
|
||||
) {}
|
||||
|
||||
public static function resolved(
|
||||
ProviderConnectionType $connectionType,
|
||||
string $tenantContext,
|
||||
string $effectiveClientId,
|
||||
string $credentialSource,
|
||||
?string $clientSecret,
|
||||
?string $authorityTenant,
|
||||
?string $redirectUri,
|
||||
): self {
|
||||
return new self(
|
||||
resolved: true,
|
||||
connectionType: $connectionType,
|
||||
tenantContext: $tenantContext,
|
||||
effectiveClientId: $effectiveClientId,
|
||||
credentialSource: $credentialSource,
|
||||
clientSecret: $clientSecret,
|
||||
authorityTenant: $authorityTenant,
|
||||
redirectUri: $redirectUri,
|
||||
reasonCode: null,
|
||||
message: null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function blocked(
|
||||
ProviderConnectionType $connectionType,
|
||||
string $tenantContext,
|
||||
string $credentialSource,
|
||||
string $reasonCode,
|
||||
?string $message = null,
|
||||
): self {
|
||||
return new self(
|
||||
resolved: false,
|
||||
connectionType: $connectionType,
|
||||
tenantContext: $tenantContext,
|
||||
effectiveClientId: null,
|
||||
credentialSource: $credentialSource,
|
||||
clientSecret: null,
|
||||
authorityTenant: null,
|
||||
redirectUri: null,
|
||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function graphOptions(array $overrides = []): array
|
||||
{
|
||||
if (! $this->resolved || $this->effectiveClientId === null || $this->clientSecret === null) {
|
||||
throw new RuntimeException($this->message ?? 'Provider identity could not be resolved.');
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'tenant' => $this->tenantContext,
|
||||
'client_id' => $this->effectiveClientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'client_request_id' => (string) Str::uuid(),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
public function effectiveReasonCode(): string
|
||||
{
|
||||
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
||||
}
|
||||
}
|
||||
126
app/Services/Providers/ProviderIdentityResolver.php
Normal file
126
app/Services/Providers/ProviderIdentityResolver.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Providers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
final class ProviderIdentityResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlatformProviderIdentityResolver $platformResolver,
|
||||
private readonly CredentialManager $credentials,
|
||||
) {}
|
||||
|
||||
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
||||
{
|
||||
$tenantContext = trim((string) $connection->entra_tenant_id);
|
||||
$connectionType = $this->resolveConnectionType($connection);
|
||||
|
||||
if ($connectionType === null) {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
tenantContext: $tenantContext !== '' ? $tenantContext : 'organizations',
|
||||
credentialSource: 'unknown',
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||
message: 'Provider connection type is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($tenantContext === '') {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: $connectionType,
|
||||
tenantContext: 'organizations',
|
||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
message: 'Provider connection is missing target tenant scope.',
|
||||
);
|
||||
}
|
||||
|
||||
if ((bool) $connection->migration_review_required) {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: $connectionType,
|
||||
tenantContext: $tenantContext,
|
||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||
message: 'Provider connection requires migration review before use.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($connectionType === ProviderConnectionType::Platform) {
|
||||
return $this->platformResolver->resolve($tenantContext);
|
||||
}
|
||||
|
||||
return $this->resolveDedicatedIdentity($connection, $tenantContext);
|
||||
}
|
||||
|
||||
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
||||
{
|
||||
$value = $connection->connection_type;
|
||||
|
||||
if ($value instanceof ProviderConnectionType) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionType::tryFrom(trim($value));
|
||||
}
|
||||
|
||||
private function resolveDedicatedIdentity(
|
||||
ProviderConnection $connection,
|
||||
string $tenantContext,
|
||||
): ProviderIdentityResolution {
|
||||
try {
|
||||
$credentials = $this->credentials->getClientCredentials($connection);
|
||||
} catch (InvalidArgumentException|RuntimeException $exception) {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
tenantContext: $tenantContext,
|
||||
credentialSource: $this->credentialSource($connection),
|
||||
reasonCode: $exception instanceof InvalidArgumentException
|
||||
? ProviderReasonCodes::DedicatedCredentialInvalid
|
||||
: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
message: $exception->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
return ProviderIdentityResolution::resolved(
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
tenantContext: $tenantContext,
|
||||
effectiveClientId: $credentials['client_id'],
|
||||
credentialSource: $this->credentialSource($connection),
|
||||
clientSecret: $credentials['client_secret'],
|
||||
authorityTenant: $tenantContext,
|
||||
redirectUri: trim((string) route('admin.consent.callback')),
|
||||
);
|
||||
}
|
||||
|
||||
private function credentialSource(ProviderConnection $connection): string
|
||||
{
|
||||
$credential = $connection->credential;
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
return ProviderCredentialSource::DedicatedManual->value;
|
||||
}
|
||||
|
||||
$source = $credential->source;
|
||||
|
||||
if ($source instanceof ProviderCredentialSource) {
|
||||
return $source->value;
|
||||
}
|
||||
|
||||
if (is_string($source) && $source !== '') {
|
||||
return $source;
|
||||
}
|
||||
|
||||
return ProviderCredentialSource::DedicatedManual->value;
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,13 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderIdentityResolver;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@ -20,6 +24,9 @@ final class StartVerification
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderOperationStartGate $providers,
|
||||
private readonly ProviderConnectionResolver $connections,
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
private readonly ProviderConnectionStateProjector $stateProjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -57,6 +64,17 @@ public function providerConnectionCheckForTenant(
|
||||
|
||||
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
$resolution = $this->connections->resolveDefault($tenant, 'microsoft');
|
||||
|
||||
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
|
||||
return $this->providerConnectionCheckUsingConnection(
|
||||
tenant: $tenant,
|
||||
connection: $resolution->connection,
|
||||
initiator: $initiator,
|
||||
extraContext: $extraContext,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->providers->start(
|
||||
tenant: $tenant,
|
||||
connection: null,
|
||||
@ -84,14 +102,41 @@ public function providerConnectionCheckUsingConnection(
|
||||
|
||||
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
return $this->providers->start(
|
||||
$identity = $this->identityResolver->resolve($connection);
|
||||
|
||||
$result = $this->providers->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator),
|
||||
initiator: $initiator,
|
||||
extraContext: $extraContext,
|
||||
extraContext: array_merge($extraContext, [
|
||||
'identity' => [
|
||||
'connection_type' => $identity->connectionType->value,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
if ($result->status === 'started') {
|
||||
$projectedState = $this->stateProjector->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $connection->consent_status,
|
||||
verificationStatus: ProviderVerificationStatus::Pending,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
|
||||
$connection->update([
|
||||
'verification_status' => ProviderVerificationStatus::Pending,
|
||||
'status' => $projectedState['status'],
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'last_error_reason_code' => null,
|
||||
'last_error_message' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function dispatchConnectionHealthCheck(OperationRun $run, Tenant $tenant, User $initiator): mixed
|
||||
|
||||
@ -36,6 +36,8 @@ class Capabilities
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE = 'workspace_managed_tenant.onboard.connection.manage';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED = 'workspace_managed_tenant.onboard.connection.manage_dedicated';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START = 'workspace_managed_tenant.onboard.verification.start';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.inventory_sync';
|
||||
@ -106,6 +108,8 @@ class Capabilities
|
||||
|
||||
public const PROVIDER_MANAGE = 'provider.manage';
|
||||
|
||||
public const PROVIDER_MANAGE_DEDICATED = 'provider.manage_dedicated';
|
||||
|
||||
public const PROVIDER_RUN = 'provider.run';
|
||||
|
||||
// Workspace baselines (Golden Master governance)
|
||||
|
||||
@ -117,6 +117,43 @@ public static function normalizeState(mixed $value): ?string
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
public static function normalizeProviderConnectionStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'granted', 'connected' => 'connected',
|
||||
'consent_required', 'required', 'needs_admin_consent', 'needs_consent', 'unknown' => 'needs_consent',
|
||||
'failed', 'revoked', 'blocked' => 'error',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderConnectionHealth(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'healthy' => 'ok',
|
||||
'blocked', 'error' => 'down',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'unknown', 'not_started' => 'not_started',
|
||||
'pending', 'in_progress' => 'in_progress',
|
||||
'degraded', 'needs_attention' => 'needs_attention',
|
||||
'blocked', 'error' => 'blocked',
|
||||
'healthy', 'ready' => 'ready',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper
|
||||
{
|
||||
$mapperClass = self::DOMAIN_MAPPERS[$domain->value] ?? null;
|
||||
|
||||
@ -10,7 +10,7 @@ final class ManagedTenantOnboardingVerificationStatusBadge implements BadgeMappe
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = BadgeCatalog::normalizeManagedTenantOnboardingVerificationStatus($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_started' => new BadgeSpec('Not started', 'gray', 'heroicon-m-minus-circle'),
|
||||
|
||||
@ -10,7 +10,7 @@ final class ProviderConnectionHealthBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = BadgeCatalog::normalizeProviderConnectionHealth($value);
|
||||
|
||||
return match ($state) {
|
||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||
|
||||
@ -10,7 +10,7 @@ final class ProviderConnectionStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = BadgeCatalog::normalizeProviderConnectionStatus($value);
|
||||
|
||||
return match ($state) {
|
||||
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Support\Links;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
|
||||
final class RequiredPermissionsLinks
|
||||
{
|
||||
@ -27,7 +28,22 @@ public static function requiredPermissions(Tenant $tenant, array $filters = []):
|
||||
|
||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
return TenantResource::adminConsentUrl($tenant);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return app(AdminConsentUrlFactory::class)->make($connection, sprintf('tenantpilot|%s', $tenant->id));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function adminConsentGuideUrl(): string
|
||||
|
||||
22
app/Support/Providers/ProviderConnectionType.php
Normal file
22
app/Support/Providers/ProviderConnectionType.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
enum ProviderConnectionType: string
|
||||
{
|
||||
case Platform = 'platform';
|
||||
case Dedicated = 'dedicated';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
|
||||
public function usesDedicatedCredential(): bool
|
||||
{
|
||||
return $this === self::Dedicated;
|
||||
}
|
||||
}
|
||||
25
app/Support/Providers/ProviderConsentStatus.php
Normal file
25
app/Support/Providers/ProviderConsentStatus.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
enum ProviderConsentStatus: string
|
||||
{
|
||||
case Unknown = 'unknown';
|
||||
case Required = 'required';
|
||||
case Granted = 'granted';
|
||||
case Failed = 'failed';
|
||||
case Revoked = 'revoked';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
|
||||
public function isGranted(): bool
|
||||
{
|
||||
return $this === self::Granted;
|
||||
}
|
||||
}
|
||||
18
app/Support/Providers/ProviderCredentialKind.php
Normal file
18
app/Support/Providers/ProviderCredentialKind.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
enum ProviderCredentialKind: string
|
||||
{
|
||||
case ClientSecret = 'client_secret';
|
||||
case Certificate = 'certificate';
|
||||
case Federated = 'federated';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
18
app/Support/Providers/ProviderCredentialSource.php
Normal file
18
app/Support/Providers/ProviderCredentialSource.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
enum ProviderCredentialSource: string
|
||||
{
|
||||
case DedicatedManual = 'dedicated_manual';
|
||||
case DedicatedImported = 'dedicated_imported';
|
||||
case LegacyMigrated = 'legacy_migrated';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -17,14 +17,36 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderConnectionMissing,
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
ProviderReasonCodes::TenantTargetMismatch => [
|
||||
ProviderReasonCodes::TenantTargetMismatch,
|
||||
ProviderReasonCodes::PlatformIdentityMissing,
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
||||
[
|
||||
'label' => 'Manage Provider Connections',
|
||||
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
'label' => $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
[
|
||||
'label' => 'Review effective app details',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
||||
[
|
||||
'label' => $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::ProviderCredentialMissing,
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
ProviderReasonCodes::ProviderAuthFailed,
|
||||
ProviderReasonCodes::ProviderConsentMissing => [
|
||||
[
|
||||
@ -32,7 +54,9 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio
|
||||
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||
],
|
||||
[
|
||||
'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections',
|
||||
'label' => $connection instanceof ProviderConnection
|
||||
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
||||
: 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
|
||||
@ -12,8 +12,24 @@ final class ProviderReasonCodes
|
||||
|
||||
public const string ProviderCredentialInvalid = 'provider_credential_invalid';
|
||||
|
||||
public const string ProviderConnectionTypeInvalid = 'provider_connection_type_invalid';
|
||||
|
||||
public const string PlatformIdentityMissing = 'platform_identity_missing';
|
||||
|
||||
public const string PlatformIdentityIncomplete = 'platform_identity_incomplete';
|
||||
|
||||
public const string DedicatedCredentialMissing = 'dedicated_credential_missing';
|
||||
|
||||
public const string DedicatedCredentialInvalid = 'dedicated_credential_invalid';
|
||||
|
||||
public const string ProviderConsentMissing = 'provider_consent_missing';
|
||||
|
||||
public const string ProviderConsentFailed = 'provider_consent_failed';
|
||||
|
||||
public const string ProviderConsentRevoked = 'provider_consent_revoked';
|
||||
|
||||
public const string ProviderConnectionReviewRequired = 'provider_connection_review_required';
|
||||
|
||||
public const string ProviderAuthFailed = 'provider_auth_failed';
|
||||
|
||||
public const string ProviderPermissionMissing = 'provider_permission_missing';
|
||||
@ -48,7 +64,15 @@ public static function all(): array
|
||||
self::ProviderConnectionInvalid,
|
||||
self::ProviderCredentialMissing,
|
||||
self::ProviderCredentialInvalid,
|
||||
self::ProviderConnectionTypeInvalid,
|
||||
self::PlatformIdentityMissing,
|
||||
self::PlatformIdentityIncomplete,
|
||||
self::DedicatedCredentialMissing,
|
||||
self::DedicatedCredentialInvalid,
|
||||
self::ProviderConsentMissing,
|
||||
self::ProviderConsentFailed,
|
||||
self::ProviderConsentRevoked,
|
||||
self::ProviderConnectionReviewRequired,
|
||||
self::ProviderAuthFailed,
|
||||
self::ProviderPermissionMissing,
|
||||
self::ProviderPermissionDenied,
|
||||
|
||||
26
app/Support/Providers/ProviderVerificationStatus.php
Normal file
26
app/Support/Providers/ProviderVerificationStatus.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
enum ProviderVerificationStatus: string
|
||||
{
|
||||
case Unknown = 'unknown';
|
||||
case Pending = 'pending';
|
||||
case Healthy = 'healthy';
|
||||
case Degraded = 'degraded';
|
||||
case Blocked = 'blocked';
|
||||
case Error = 'error';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return in_array($this, [self::Healthy, self::Degraded, self::Blocked, self::Error], true);
|
||||
}
|
||||
}
|
||||
@ -58,6 +58,21 @@ public static function identity(OperationRun $run): array
|
||||
$identity['entra_tenant_id'] = trim($entraTenantId);
|
||||
}
|
||||
|
||||
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
|
||||
if (is_string($connectionType) && trim($connectionType) !== '') {
|
||||
$identity['connection_type'] = trim($connectionType);
|
||||
}
|
||||
|
||||
$effectiveClientId = $context['identity']['effective_client_id'] ?? null;
|
||||
if (is_string($effectiveClientId) && trim($effectiveClientId) !== '') {
|
||||
$identity['effective_client_id'] = trim($effectiveClientId);
|
||||
}
|
||||
|
||||
$credentialSource = $context['identity']['credential_source'] ?? null;
|
||||
if (is_string($credentialSource) && trim($credentialSource) !== '') {
|
||||
$identity['credential_source'] = trim($credentialSource);
|
||||
}
|
||||
|
||||
return $identity;
|
||||
}
|
||||
|
||||
@ -79,7 +94,15 @@ private static function blockedMessage(OperationRun $run): string
|
||||
return trim((string) $firstFailure['message']);
|
||||
}
|
||||
|
||||
return 'Operation blocked due to provider configuration.';
|
||||
return match (self::normalizedReasonCode(($run->context['reason_code'] ?? null))) {
|
||||
ProviderReasonCodes::PlatformIdentityMissing => 'Platform app identity is not configured.',
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete => 'Platform app identity is incomplete.',
|
||||
ProviderReasonCodes::DedicatedCredentialMissing => 'Dedicated connection credentials are missing.',
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => 'Dedicated connection credentials are invalid.',
|
||||
ProviderReasonCodes::ProviderConsentFailed => 'Admin consent failed. Retry admin consent before verification can proceed.',
|
||||
ProviderReasonCodes::ProviderConsentRevoked => 'Admin consent was revoked. Grant admin consent again before verification can proceed.',
|
||||
default => 'Operation blocked due to provider configuration.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -109,6 +132,30 @@ private static function evidence(OperationRun $run, array $context): array
|
||||
];
|
||||
}
|
||||
|
||||
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
|
||||
if (is_string($connectionType) && trim($connectionType) !== '') {
|
||||
$evidence[] = [
|
||||
'kind' => 'connection_type',
|
||||
'value' => trim($connectionType),
|
||||
];
|
||||
}
|
||||
|
||||
$credentialSource = $context['identity']['credential_source'] ?? null;
|
||||
if (is_string($credentialSource) && trim($credentialSource) !== '') {
|
||||
$evidence[] = [
|
||||
'kind' => 'credential_source',
|
||||
'value' => trim($credentialSource),
|
||||
];
|
||||
}
|
||||
|
||||
$effectiveClientId = $context['identity']['effective_client_id'] ?? null;
|
||||
if (is_string($effectiveClientId) && trim($effectiveClientId) !== '') {
|
||||
$evidence[] = [
|
||||
'kind' => 'app_id',
|
||||
'value' => trim($effectiveClientId),
|
||||
];
|
||||
}
|
||||
|
||||
$evidence[] = [
|
||||
'kind' => 'operation_run_id',
|
||||
'value' => (int) $run->getKey(),
|
||||
|
||||
@ -42,7 +42,7 @@ public static function buildChecks(Tenant $tenant, array $permissions, ?array $i
|
||||
$inventoryMessage = $inventory['message'] ?? null;
|
||||
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
||||
? trim($inventoryMessage)
|
||||
: 'Unable to refresh observed permissions inventory during this run. Retry verification.';
|
||||
: self::inventoryMessage($inventoryReasonCode);
|
||||
|
||||
$inventoryEvidence = self::inventoryEvidence($inventory);
|
||||
|
||||
@ -382,6 +382,19 @@ private static function inventoryEvidence(array $inventory): array
|
||||
return $pointers;
|
||||
}
|
||||
|
||||
private static function inventoryMessage(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::PlatformIdentityMissing => 'Platform app identity is not configured. Verification used no runtime fallback.',
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete => 'Platform app identity is incomplete. Fix the central platform configuration, then retry verification.',
|
||||
ProviderReasonCodes::DedicatedCredentialMissing => 'Dedicated connection credentials are missing. Add credentials, then retry verification.',
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => 'Dedicated connection credentials are invalid. Update the dedicated credential, then retry verification.',
|
||||
ProviderReasonCodes::ProviderConsentFailed => 'Admin consent failed. Retry admin consent before verification can continue.',
|
||||
ProviderReasonCodes::ProviderConsentRevoked => 'Admin consent was revoked. Grant admin consent again before verification can continue.',
|
||||
default => 'Unable to refresh observed permissions inventory during this run. Retry verification.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
|
||||
@ -14,6 +14,8 @@ final class VerificationReportSanitizer
|
||||
private const ALLOWED_EVIDENCE_KINDS = [
|
||||
'provider_connection_id',
|
||||
'entra_tenant_id',
|
||||
'connection_type',
|
||||
'credential_source',
|
||||
'organization_id',
|
||||
'http_status',
|
||||
'app_id',
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -44,8 +47,17 @@ public function definition(): array
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'display_name' => fake()->company(),
|
||||
'is_default' => false,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => 'needs_consent',
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => 'unknown',
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'scopes_granted' => [],
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => null,
|
||||
@ -53,4 +65,41 @@ public function definition(): array
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function platform(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function dedicated(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function consentGranted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'connected',
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'consent_granted_at' => now(),
|
||||
'consent_last_checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifiedHealthy(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'connected',
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'consent_granted_at' => now(),
|
||||
'consent_last_checked_at' => now(),
|
||||
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||
'health_status' => 'ok',
|
||||
'last_health_check_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -16,12 +18,25 @@ class ProviderCredentialFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'provider_connection_id' => ProviderConnection::factory(),
|
||||
'provider_connection_id' => ProviderConnection::factory()->dedicated(),
|
||||
'type' => 'client_secret',
|
||||
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
|
||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
||||
'last_rotated_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'payload' => [
|
||||
'client_id' => fake()->uuid(),
|
||||
'client_secret' => fake()->sha1(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function legacyMigrated(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'source' => ProviderCredentialSource::LegacyMigrated->value,
|
||||
'last_rotated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_connections')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->string('connection_type')
|
||||
->default(ProviderConnectionType::Platform->value)
|
||||
->after('is_default');
|
||||
$table->string('consent_status')
|
||||
->default(ProviderConsentStatus::Required->value)
|
||||
->after('status');
|
||||
$table->timestamp('consent_granted_at')->nullable()->after('consent_status');
|
||||
$table->timestamp('consent_last_checked_at')->nullable()->after('consent_granted_at');
|
||||
$table->string('consent_error_code')->nullable()->after('consent_last_checked_at');
|
||||
$table->string('consent_error_message')->nullable()->after('consent_error_code');
|
||||
$table->string('verification_status')
|
||||
->default(ProviderVerificationStatus::Unknown->value)
|
||||
->after('consent_error_message');
|
||||
$table->boolean('migration_review_required')->default(false)->after('health_status');
|
||||
$table->timestamp('migration_reviewed_at')->nullable()->after('migration_review_required');
|
||||
|
||||
$table->index(
|
||||
['workspace_id', 'provider', 'connection_type'],
|
||||
'provider_connections_workspace_provider_connection_type_idx',
|
||||
);
|
||||
$table->index(
|
||||
['workspace_id', 'provider', 'consent_status'],
|
||||
'provider_connections_workspace_provider_consent_status_idx',
|
||||
);
|
||||
$table->index(
|
||||
['workspace_id', 'provider', 'verification_status'],
|
||||
'provider_connections_workspace_provider_verification_status_idx',
|
||||
);
|
||||
$table->index(
|
||||
['workspace_id', 'provider', 'migration_review_required'],
|
||||
'provider_connections_workspace_provider_review_required_idx',
|
||||
);
|
||||
});
|
||||
|
||||
DB::statement(sprintf(
|
||||
<<<'SQL'
|
||||
UPDATE provider_connections
|
||||
SET connection_type = '%s',
|
||||
consent_status = CASE
|
||||
WHEN status = 'connected' THEN '%s'
|
||||
WHEN COALESCE(last_error_reason_code, '') = 'provider_consent_revoked' THEN '%s'
|
||||
WHEN status = 'error' AND COALESCE(last_error_reason_code, '') <> 'provider_consent_missing' THEN '%s'
|
||||
ELSE '%s'
|
||||
END,
|
||||
consent_granted_at = CASE
|
||||
WHEN status = 'connected' THEN COALESCE(last_health_check_at, updated_at, created_at)
|
||||
ELSE NULL
|
||||
END,
|
||||
consent_last_checked_at = CASE
|
||||
WHEN status = 'connected' THEN COALESCE(last_health_check_at, updated_at, created_at)
|
||||
ELSE NULL
|
||||
END,
|
||||
consent_error_code = CASE
|
||||
WHEN status = 'error' THEN last_error_reason_code
|
||||
ELSE NULL
|
||||
END,
|
||||
consent_error_message = CASE
|
||||
WHEN status = 'error' THEN last_error_message
|
||||
ELSE NULL
|
||||
END,
|
||||
verification_status = CASE
|
||||
WHEN health_status = 'ok' THEN '%s'
|
||||
WHEN health_status = 'degraded' THEN '%s'
|
||||
WHEN health_status = 'down' THEN '%s'
|
||||
ELSE '%s'
|
||||
END
|
||||
SQL,
|
||||
ProviderConnectionType::Platform->value,
|
||||
ProviderConsentStatus::Granted->value,
|
||||
ProviderConsentStatus::Revoked->value,
|
||||
ProviderConsentStatus::Failed->value,
|
||||
ProviderConsentStatus::Required->value,
|
||||
ProviderVerificationStatus::Healthy->value,
|
||||
ProviderVerificationStatus::Degraded->value,
|
||||
ProviderVerificationStatus::Error->value,
|
||||
ProviderVerificationStatus::Unknown->value,
|
||||
));
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_connections')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->dropIndex('provider_connections_workspace_provider_connection_type_idx');
|
||||
$table->dropIndex('provider_connections_workspace_provider_consent_status_idx');
|
||||
$table->dropIndex('provider_connections_workspace_provider_verification_status_idx');
|
||||
$table->dropIndex('provider_connections_workspace_provider_review_required_idx');
|
||||
|
||||
$table->dropColumn([
|
||||
'connection_type',
|
||||
'consent_status',
|
||||
'consent_granted_at',
|
||||
'consent_last_checked_at',
|
||||
'consent_error_code',
|
||||
'consent_error_message',
|
||||
'verification_status',
|
||||
'migration_review_required',
|
||||
'migration_reviewed_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_credentials')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('provider_credentials', function (Blueprint $table): void {
|
||||
$table->string('credential_kind')->nullable()->after('type');
|
||||
$table->string('source')->nullable()->after('credential_kind');
|
||||
$table->timestamp('last_rotated_at')->nullable()->after('source');
|
||||
$table->timestamp('expires_at')->nullable()->after('last_rotated_at');
|
||||
});
|
||||
|
||||
DB::statement(sprintf(
|
||||
<<<'SQL'
|
||||
UPDATE provider_credentials
|
||||
SET credential_kind = COALESCE(NULLIF(type, ''), '%s'),
|
||||
source = COALESCE(source, '%s'),
|
||||
last_rotated_at = COALESCE(last_rotated_at, updated_at, created_at)
|
||||
SQL,
|
||||
ProviderCredentialKind::ClientSecret->value,
|
||||
ProviderCredentialSource::LegacyMigrated->value,
|
||||
));
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_credentials')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('provider_credentials', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'credential_kind',
|
||||
'source',
|
||||
'last_rotated_at',
|
||||
'expires_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -18,6 +18,10 @@
|
||||
<div class="card">
|
||||
<h1>Admin Consent Status</h1>
|
||||
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
|
||||
@isset($connection)
|
||||
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
|
||||
<p><strong>Verification state:</strong> {{ ucfirst($connection->verification_status->value) }}</p>
|
||||
@endisset
|
||||
<p>
|
||||
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">
|
||||
Status: {{ ucfirst(str_replace('_', ' ', $status)) }}
|
||||
@ -26,9 +30,9 @@
|
||||
@if($error)
|
||||
<p><strong>Error:</strong> {{ $error }}</p>
|
||||
@elseif($consentGranted === false)
|
||||
<p>Admin consent wurde abgelehnt.</p>
|
||||
<p>Admin consent was not granted. Review the connection state and try again.</p>
|
||||
@else
|
||||
<p>Admin consent wurde bestätigt.</p>
|
||||
<p>Admin consent was granted. Run verification again to confirm operational readiness.</p>
|
||||
@endif
|
||||
|
||||
<p>
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Platform Provider Identity Standardization
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-13
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/137-platform-provider-identity/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation completed in one pass against the repo spec template and constitution-alignment requirements.
|
||||
- No clarification markers remain; the feature is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,286 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Provider Identity Standardization Contract
|
||||
version: 0.1.0
|
||||
summary: Internal contract for platform-default Microsoft provider connection flows.
|
||||
description: Logical internal action contract for existing authorized provider-connection surfaces. Operations may be implemented by existing Filament or Livewire action handlers and controller endpoints without introducing new standalone public routes, provided the documented authorization, confirmation, audit, and payload semantics are preserved.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/tenants/{tenant}/provider-connections:
|
||||
post:
|
||||
summary: Create a provider connection for a tenant
|
||||
description: Creates a platform connection by default. Dedicated mode requires explicit opt-in and stronger authorization.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Provider connection created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionResponse'
|
||||
'403':
|
||||
description: Member lacks the required capability
|
||||
'404':
|
||||
description: Workspace or tenant scope not accessible
|
||||
/provider-connections/{connection}/admin-consent-link:
|
||||
post:
|
||||
summary: Generate canonical admin consent link
|
||||
parameters:
|
||||
- in: path
|
||||
name: connection
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Consent link generated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminConsentLinkResponse'
|
||||
'403':
|
||||
description: Member lacks the required capability
|
||||
'404':
|
||||
description: Connection not accessible in tenant scope
|
||||
/consent/callback:
|
||||
get:
|
||||
summary: Handle Microsoft admin consent callback
|
||||
parameters:
|
||||
- in: query
|
||||
name: state
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: tenant
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: admin_consent
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: error
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Consent callback rendered and connection updated
|
||||
'403':
|
||||
description: Invalid callback state
|
||||
'404':
|
||||
description: Tenant or workspace context unavailable
|
||||
/provider-connections/{connection}/verification:
|
||||
post:
|
||||
summary: Start provider connection verification
|
||||
parameters:
|
||||
- in: path
|
||||
name: connection
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'202':
|
||||
description: Verification run queued or deduped
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationStartResponse'
|
||||
'403':
|
||||
description: Member lacks the provider run capability
|
||||
'404':
|
||||
description: Tenant scope not accessible
|
||||
/provider-connections/{connection}/connection-type:
|
||||
put:
|
||||
summary: Change the connection type explicitly
|
||||
description: Switching to or from dedicated requires stronger authorization and confirmation.
|
||||
parameters:
|
||||
- in: path
|
||||
name: connection
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- connection_type
|
||||
properties:
|
||||
connection_type:
|
||||
type: string
|
||||
enum: [platform, dedicated]
|
||||
responses:
|
||||
'200':
|
||||
description: Connection type changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionResponse'
|
||||
'403':
|
||||
description: Member lacks dedicated-management capability
|
||||
'404':
|
||||
description: Connection not accessible in tenant scope
|
||||
/provider-connections/{connection}/dedicated-credential:
|
||||
put:
|
||||
summary: Create or rotate dedicated credential
|
||||
parameters:
|
||||
- in: path
|
||||
name: connection
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DedicatedCredentialRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Dedicated credential stored or rotated
|
||||
'403':
|
||||
description: Member lacks dedicated-management capability
|
||||
'404':
|
||||
description: Connection not accessible in tenant scope
|
||||
delete:
|
||||
summary: Delete dedicated credential
|
||||
description: Destructive operation that requires stronger authorization and confirmation.
|
||||
parameters:
|
||||
- in: path
|
||||
name: connection
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'204':
|
||||
description: Dedicated credential deleted
|
||||
'403':
|
||||
description: Member lacks dedicated-management capability
|
||||
'404':
|
||||
description: Connection not accessible in tenant scope
|
||||
components:
|
||||
schemas:
|
||||
ProviderConnectionCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
- entra_tenant_id
|
||||
- display_name
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [microsoft]
|
||||
entra_tenant_id:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
is_default:
|
||||
type: boolean
|
||||
default: true
|
||||
connection_type:
|
||||
type: string
|
||||
enum: [platform, dedicated]
|
||||
default: platform
|
||||
dedicated_credential:
|
||||
$ref: '#/components/schemas/DedicatedCredentialRequest'
|
||||
nullable: true
|
||||
DedicatedCredentialRequest:
|
||||
type: object
|
||||
required:
|
||||
- credential_kind
|
||||
- client_id
|
||||
- client_secret
|
||||
properties:
|
||||
credential_kind:
|
||||
type: string
|
||||
enum: [client_secret]
|
||||
source:
|
||||
type: string
|
||||
enum: [dedicated_manual, dedicated_imported, legacy_migrated]
|
||||
client_id:
|
||||
type: string
|
||||
client_secret:
|
||||
type: string
|
||||
writeOnly: true
|
||||
ProviderConnectionResponse:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- provider
|
||||
- entra_tenant_id
|
||||
- display_name
|
||||
- connection_type
|
||||
- consent_status
|
||||
- verification_status
|
||||
- health_status
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
provider:
|
||||
type: string
|
||||
entra_tenant_id:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
connection_type:
|
||||
type: string
|
||||
enum: [platform, dedicated]
|
||||
consent_status:
|
||||
type: string
|
||||
enum: [unknown, required, granted, failed, revoked]
|
||||
verification_status:
|
||||
type: string
|
||||
enum: [unknown, pending, healthy, degraded, blocked, error]
|
||||
health_status:
|
||||
type: string
|
||||
effective_app_id:
|
||||
type: string
|
||||
credential_source:
|
||||
type: string
|
||||
migration_review_required:
|
||||
type: boolean
|
||||
default: false
|
||||
AdminConsentLinkResponse:
|
||||
type: object
|
||||
required:
|
||||
- url
|
||||
- client_id
|
||||
- connection_type
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
client_id:
|
||||
type: string
|
||||
connection_type:
|
||||
type: string
|
||||
enum: [platform, dedicated]
|
||||
VerificationStartResponse:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- operation_run_id
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [started, deduped, scope_busy, blocked]
|
||||
operation_run_id:
|
||||
type: integer
|
||||
182
specs/137-platform-provider-identity/data-model.md
Normal file
182
specs/137-platform-provider-identity/data-model.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Data Model: Platform Provider Identity Standardization
|
||||
|
||||
## 1. ProviderConnection
|
||||
|
||||
**Purpose**: Tenant-owned record that models the tenant’s Microsoft connection state without storing centrally managed platform secrets.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `id` | bigint | yes | Existing primary key |
|
||||
| `workspace_id` | bigint FK | yes | Existing workspace ownership |
|
||||
| `tenant_id` | bigint FK | yes | Existing tenant ownership |
|
||||
| `provider` | string | yes | Existing, remains `microsoft` for this feature scope |
|
||||
| `entra_tenant_id` | string | yes | Existing target tenant directory ID |
|
||||
| `display_name` | string | yes | Existing operator-visible label |
|
||||
| `is_default` | boolean | yes | Existing default invariant per tenant/provider |
|
||||
| `connection_type` | string | yes | New; `platform` or `dedicated` |
|
||||
| `status` | string | yes | Existing backward-compatible summary; no longer canonical for consent or verification |
|
||||
| `consent_status` | string | yes | New; `unknown`, `required`, `granted`, `failed`, `revoked` |
|
||||
| `consent_granted_at` | timestamp nullable | no | New |
|
||||
| `consent_last_checked_at` | timestamp nullable | no | New |
|
||||
| `consent_error_code` | string nullable | no | New; sanitized identity-provider or platform reason code |
|
||||
| `consent_error_message` | string nullable | no | New; sanitized operator-safe message |
|
||||
| `verification_status` | string | yes | New; `unknown`, `pending`, `healthy`, `degraded`, `blocked`, `error` |
|
||||
| `health_status` | string | yes | Existing operational summary retained for compatibility |
|
||||
| `last_health_check_at` | timestamp nullable | no | Existing |
|
||||
| `last_error_reason_code` | string nullable | no | Existing, remains runtime-oriented |
|
||||
| `last_error_message` | string nullable | no | Existing, sanitized |
|
||||
| `scopes_granted` | jsonb | yes | Existing; may continue to hold observed permission snapshots |
|
||||
| `metadata` | jsonb | yes | Existing; used for migration-review flags and display-only hints such as effective app metadata |
|
||||
| `created_at` / `updated_at` | timestamps | yes | Existing |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `provider` must remain within supported provider types and is `microsoft` for this feature.
|
||||
- `connection_type` must be one of `platform` or `dedicated`.
|
||||
- `consent_status` must be one of the defined consent states.
|
||||
- `verification_status` must be one of the defined verification states.
|
||||
- `workspace_id` and `tenant_id` must match the resolved tenant scope.
|
||||
- `platform` connections must not require a `ProviderCredential` row.
|
||||
- `dedicated` connections may not be marked operationally ready without a valid credential row.
|
||||
|
||||
### Relationships
|
||||
|
||||
- Belongs to one `Tenant`
|
||||
- Belongs to one `Workspace`
|
||||
- Has zero or one `ProviderCredential`
|
||||
- Emits many `AuditLog` entries indirectly via resource or service actions
|
||||
|
||||
### State Transitions
|
||||
|
||||
#### Standard platform flow
|
||||
|
||||
1. Create connection
|
||||
- `connection_type=platform`
|
||||
- `consent_status=required`
|
||||
- `verification_status=unknown`
|
||||
- `status=needs_consent`
|
||||
2. Consent granted
|
||||
- `consent_status=granted`
|
||||
- `consent_granted_at` set
|
||||
- `verification_status` remains `unknown` or moves to `pending` when verification starts
|
||||
3. Verification succeeds
|
||||
- `verification_status=healthy`
|
||||
- `health_status=healthy` or existing canonical success value
|
||||
- `status=connected`
|
||||
4. Verification fails after granted consent
|
||||
- `consent_status` stays `granted`
|
||||
- `verification_status=degraded`, `blocked`, or `error`
|
||||
- `status` remains a compatibility summary, not the source of truth
|
||||
|
||||
#### Dedicated flow
|
||||
|
||||
1. Create dedicated connection
|
||||
- `connection_type=dedicated`
|
||||
- `consent_status=required` or `unknown`
|
||||
- `verification_status=unknown`
|
||||
2. Dedicated credential added or rotated
|
||||
- Connection remains non-ready until consent and verification succeed
|
||||
3. Dedicated credential missing or invalid
|
||||
- `verification_status=blocked`
|
||||
- runtime reason code indicates dedicated credential problem
|
||||
|
||||
#### Migration review flow
|
||||
|
||||
- `connection_type` is still set to `platform` or `dedicated`
|
||||
- `metadata.legacy_identity_review_required=true`
|
||||
- audit and UI show the record needs explicit operator review before final cleanup
|
||||
|
||||
## 2. ProviderCredential
|
||||
|
||||
**Purpose**: Tenant-owned credential material used only for explicit dedicated connections.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `id` | bigint | yes | Existing primary key |
|
||||
| `provider_connection_id` | bigint FK | yes | Existing one-to-one relationship |
|
||||
| `type` | string | yes | Existing legacy type; retained temporarily for backward compatibility |
|
||||
| `credential_kind` | string | no | New; `client_secret`, later `certificate` or `federated` |
|
||||
| `source` | string | no | New; `dedicated_manual`, `dedicated_imported`, `legacy_migrated` |
|
||||
| `payload` | encrypted array | yes | Existing encrypted payload, only for dedicated credentials |
|
||||
| `created_at` / `updated_at` | timestamps | yes | Existing |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `provider_connection_id` must reference a `ProviderConnection` with `connection_type=dedicated`.
|
||||
- `payload` must contain the fields required by `credential_kind`.
|
||||
- `client_secret` payloads must include a non-empty `client_id` and `client_secret`.
|
||||
- `tenant_id` or authority hints inside the payload, if present, must match the parent connection’s target tenant.
|
||||
|
||||
### Relationships
|
||||
|
||||
- Belongs to one `ProviderConnection`
|
||||
|
||||
## 3. PlatformProviderIdentity
|
||||
|
||||
**Purpose**: Virtual central identity returned by the platform resolver. Not stored in tenant-owned tables.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `provider` | string | yes | `microsoft` |
|
||||
| `client_id` | string | yes | Central multitenant app ID |
|
||||
| `credential_mode` | string | yes | Initially `client_secret` |
|
||||
| `client_secret` or `secret_reference` | string | yes | Resolved centrally; never copied into tenant tables |
|
||||
| `authority_tenant` | string | yes | Usually platform home tenant or `organizations` routing metadata |
|
||||
| `redirect_uri` | string | yes | Canonical callback route |
|
||||
| `redirect_source` | string | yes | Metadata for display and debugging |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `client_id` must be present for all platform flows.
|
||||
- A secret or secret reference must be resolvable for runtime use.
|
||||
- Redirect URI must match the canonical consent callback route.
|
||||
|
||||
## 4. ProviderIdentityResolution
|
||||
|
||||
**Purpose**: Virtual service result consumed by `ProviderGateway`, verification, and consent generation.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `connection_type` | string | yes | `platform` or `dedicated` |
|
||||
| `effective_client_id` | string | yes | App identity used for consent and runtime |
|
||||
| `credential_source` | string | yes | `platform_config` or dedicated credential source |
|
||||
| `tenant_context` | string | yes | Target customer tenant for Graph calls |
|
||||
| `resolved` | boolean | yes | Whether runtime resolution succeeded |
|
||||
| `reason_code` | string nullable | no | Stable blocker when not resolved |
|
||||
| `message` | string nullable | no | Sanitized operator-safe explanation |
|
||||
|
||||
## 5. MigrationClassificationResult
|
||||
|
||||
**Purpose**: Explicit migration outcome for existing provider connections.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---:|---|
|
||||
| `provider_connection_id` | bigint | yes | Record being classified |
|
||||
| `suggested_connection_type` | string | yes | `platform` or `dedicated` |
|
||||
| `review_required` | boolean | yes | True for contradictory hybrid cases |
|
||||
| `signals` | array | yes | Evidence such as tenant app fields, credential payload, consent/runtime mismatch |
|
||||
| `source` | string | yes | `migration_scan`, `operator_override`, or similar |
|
||||
|
||||
## 6. AuditLog Impact
|
||||
|
||||
This feature does not add a new audit table. It extends audit usage for:
|
||||
|
||||
- connection created
|
||||
- connection type changed
|
||||
- consent started
|
||||
- consent succeeded or failed
|
||||
- consent revoked or missing detected
|
||||
- verification succeeded or failed
|
||||
- dedicated credential created, rotated, or deleted
|
||||
- migration classification applied
|
||||
- migration review required flagged or resolved
|
||||
178
specs/137-platform-provider-identity/plan.md
Normal file
178
specs/137-platform-provider-identity/plan.md
Normal file
@ -0,0 +1,178 @@
|
||||
# Implementation Plan: Platform Provider Identity Standardization
|
||||
|
||||
**Branch**: `137-platform-provider-identity` | **Date**: 2026-03-13 | **Spec**: [specs/137-platform-provider-identity/spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/137-platform-provider-identity/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Standardize Microsoft provider connections around one centrally managed platform app identity while keeping dedicated customer-specific app registrations as an explicitly authorized exception.
|
||||
|
||||
The implementation adds an explicit `connection_type` model, splits consent state from verification state, centralizes platform identity resolution and admin-consent URL generation, and updates runtime provider resolution so platform connections no longer depend on per-connection credentials. Existing onboarding, callback, verification, and downstream provider consumers will be refactored to consume the same identity-selection path, while a migration classifier marks legacy connections as platform, dedicated, or review-required without introducing a third runtime mode.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||
**Storage**: PostgreSQL via Laravel migrations and encrypted model casts
|
||||
**Testing**: Pest feature and unit tests, including Livewire component tests run via `vendor/bin/sail artisan test --compact`
|
||||
**Target Platform**: Laravel web application with Filament admin panels and queued jobs, deployed in containers
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Consent URL generation and provider connection page rendering remain DB-only and deterministic; remote verification and provider operations remain queued or explicit action-triggered; platform identity resolution is constant-time from config or secret abstraction
|
||||
**Constraints**: Platform secrets must never be persisted into tenant-owned rows; Graph calls must continue through `GraphClientInterface` and `config/graph_contracts.php`; platform connections must not silently fall back to tenant-local or dedicated credentials; tenant/workspace non-members must get 404 and members lacking capability must get 403; destructive credential or type mutations require confirmation
|
||||
**Scale/Scope**: Cross-cutting provider foundation change touching onboarding, callback, verification, provider operations, audit events, migrations, and regression guards for all Microsoft provider consumers
|
||||
|
||||
### Filament v5 Implementation Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: Maintained. All in-scope Filament and onboarding surfaces remain Livewire v4 compatible.
|
||||
- **Provider registration location**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- **Global search rule**: `ProviderConnectionResource` already has Edit/View pages but global search is disabled, so no new global-search obligation is introduced. `TenantResource` remains outside this feature’s global-search scope.
|
||||
- **Destructive actions**: Dedicated credential delete, credential rotate, and connection-type changes away from dedicated remain destructive-like and must execute through confirmed actions with server-side authorization.
|
||||
- **Asset strategy**: No new Filament assets are planned. Existing deployment practice still includes `php artisan filament:assets`; this feature does not add global assets or on-demand asset registrations.
|
||||
- **Testing plan**: Add unit tests for resolver and consent factory behavior, feature or Livewire tests for onboarding and provider connection management, migration-classification tests, authorization tests, and regression guards against legacy credential fallback.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. This feature changes provider identity selection, not inventory or snapshot ownership.
|
||||
- Read/write separation: PASS. Connection creation, connection-type changes, consent initiation, dedicated credential mutations, and migration overrides remain explicit writes with audit coverage and focused tests.
|
||||
- Graph contract path: PASS. Runtime Graph calls remain behind `GraphClientInterface` and the existing contract registry; no direct endpoint shortcuts are introduced.
|
||||
- Deterministic capabilities: PASS. Authorization remains tied to the canonical capability registry; no new ad-hoc capability strings are needed.
|
||||
- RBAC-UX planes + 404/403 semantics: PASS. In-scope routes stay in the tenant/admin plane, cross-plane access remains 404, tenant/workspace non-members remain 404, and members lacking capability remain 403.
|
||||
- Workspace isolation: PASS. Provider connection management remains workspace-bound and tenant-scoped.
|
||||
- Destructive confirmation standard: PASS WITH WORK. Dedicated credential deletion, dedicated credential rotation, and explicit connection-type changes must all use `->requiresConfirmation()` and server-side checks.
|
||||
- Global search tenant safety: PASS. `ProviderConnectionResource` remains non-globally-searchable, so no new tenant-leak surface is introduced.
|
||||
- Tenant isolation: PASS. All provider reads and writes continue to require the resolved tenant scope.
|
||||
- Run observability: PASS. The admin consent handshake stays synchronous under the auth exception; verification and provider operations continue using `OperationRun` where they already do.
|
||||
- Ops-UX 3-surface feedback: PASS. Existing verification runs keep the current queued toast, progress surfaces, and terminal notification pattern.
|
||||
- Ops-UX lifecycle: PASS. No direct `OperationRun` state transitions are added outside `OperationRunService`.
|
||||
- Ops-UX summary counts: PASS. No new non-numeric summary payload is planned.
|
||||
- Ops-UX guards: PASS. Existing operation guards remain, and this feature adds fallback guards for provider identity resolution.
|
||||
- Ops-UX system runs: PASS. No new system-wide notification fan-out is introduced.
|
||||
- Automation: PASS. Existing queued verification and provider jobs remain deduped and idempotent.
|
||||
- Data minimization: PASS. Platform secrets stay centralized; audit, verification, and UI outputs remain redacted.
|
||||
- Badge semantics (BADGE-001): PASS WITH WORK. New or newly surfaced consent and verification states must use centralized badge rendering and receive regression coverage.
|
||||
- UI naming (UI-NAMING-001): PASS WITH WORK. Platform connection, Dedicated connection, Grant admin consent, and Run verification again must be consistent across wizard, detail, next-step, and audit surfaces.
|
||||
- Filament UI Action Surface Contract: PASS WITH WORK. Existing ProviderConnectionResource and onboarding wizard surfaces need form and action updates but remain within the contract.
|
||||
- Filament UI UX-001: PASS WITH WORK. Create and edit flows must present platform-default onboarding in structured sections and remove naked credential inputs from the standard path.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/137-platform-provider-identity/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── provider-identity.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
│ └── Resources/
|
||||
│ ├── ProviderConnectionResource.php
|
||||
│ ├── ProviderConnectionResource/Pages/
|
||||
│ └── TenantResource.php
|
||||
├── Http/Controllers/
|
||||
│ ├── AdminConsentCallbackController.php
|
||||
│ └── TenantOnboardingController.php
|
||||
├── Models/
|
||||
│ ├── ProviderConnection.php
|
||||
│ ├── ProviderCredential.php
|
||||
│ └── Tenant.php
|
||||
├── Observers/ProviderCredentialObserver.php
|
||||
├── Services/
|
||||
│ ├── Intune/
|
||||
│ ├── Providers/
|
||||
│ └── Verification/
|
||||
├── Support/
|
||||
│ ├── Links/RequiredPermissionsLinks.php
|
||||
│ ├── Providers/
|
||||
│ └── Verification/
|
||||
└── Jobs/ProviderConnectionHealthCheckJob.php
|
||||
|
||||
config/
|
||||
├── graph.php
|
||||
└── graph_contracts.php
|
||||
|
||||
database/
|
||||
├── factories/
|
||||
└── migrations/
|
||||
|
||||
resources/views/
|
||||
└── admin-consent-callback.blade.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Audit/
|
||||
│ ├── Guards/
|
||||
│ ├── Onboarding/
|
||||
│ └── ProviderConnections/
|
||||
└── Unit/
|
||||
├── Policies/
|
||||
└── Providers/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the Laravel monolith structure. Provider identity logic will be centralized under `app/Services/Providers/*`, with Filament and controller surfaces consuming those services instead of reading credential sources directly.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| N/A | N/A | N/A |
|
||||
|
||||
## Phase 0 — Outline & Research (complete)
|
||||
|
||||
- Output: [specs/137-platform-provider-identity/research.md](research.md)
|
||||
- Resolved the main design choices: string-backed `connection_type`, centralized platform identity resolver, canonical admin-consent builder, binary runtime mode with explicit migration-review metadata, and separate consent versus verification state.
|
||||
- Confirmed current code hotspots: runtime provider gateway and resolver assume every connection has dedicated credentials, onboarding still collects client ID and secret, and consent URL generation still falls back to legacy tenant fields.
|
||||
|
||||
## Phase 1 — Design & Contracts (complete)
|
||||
|
||||
- Output: [specs/137-platform-provider-identity/data-model.md](data-model.md)
|
||||
- Output: [specs/137-platform-provider-identity/quickstart.md](quickstart.md)
|
||||
- Output: [specs/137-platform-provider-identity/contracts/provider-identity.openapi.yaml](contracts/provider-identity.openapi.yaml)
|
||||
|
||||
### Post-design Constitution Re-check
|
||||
|
||||
- PASS: Platform identity remains centralized; no tenant-owned secret duplication is introduced.
|
||||
- PASS: Verification and downstream provider operations continue to use the Graph contract path and existing `OperationRun` observability model.
|
||||
- PASS WITH WORK: Filament create/edit and onboarding surfaces must be updated to remove standard credential entry while preserving confirmation and stronger authorization for dedicated override flows.
|
||||
- PASS WITH WORK: New consent and verification statuses require centralized badge rendering and test coverage.
|
||||
|
||||
## Phase 2 — Implementation Planning (next)
|
||||
|
||||
`tasks.md` will be produced by `/speckit.tasks` and should cover, at minimum:
|
||||
|
||||
- Schema migration: add `connection_type`, `consent_status`, consent metadata, `verification_status`, and migration-review metadata to `provider_connections`, and add dedicated credential lifecycle metadata to `provider_credentials`, while preserving backward-compatible `status` and `health_status` semantics.
|
||||
- Central services: add `PlatformProviderIdentityResolver`, `ProviderIdentityResolver`, `AdminConsentUrlFactory`, `ProviderConnectionClassifier`, and `ProviderConnectionStateProjector`.
|
||||
- Runtime cutover: refactor `CredentialManager`, `ProviderGateway`, `ProviderConnectionResolver`, `MicrosoftGraphOptionsResolver`, and downstream provider consumers so platform connections use the central identity and dedicated connections use `ProviderCredential` only.
|
||||
- Consent cutover: remove consent URL generation from tenant-field fallback logic and route all consent surfaces through the canonical factory.
|
||||
- Onboarding UX: make platform the default connection path, show platform app identity read-only, remove standard credential entry, and gate dedicated override behind stronger authorization.
|
||||
- Callback and verification: persist explicit connection type, keep consent and verification states separate, and preserve the existing verification `OperationRun` pattern.
|
||||
- Migration and safeguards: classify existing connections into platform, dedicated, or review-required through an explicit post-migration command or queued backfill, block new standard reads from legacy tenant app fields, and add guard tests against fallback regressions.
|
||||
- Audit and RBAC: extend auditable events for connection-type changes, consent start and result, consent revocation detection, verification results, dedicated credential lifecycle changes, and migration classification outcomes; enforce stronger dedicated-management authorization; and add positive and negative authorization coverage plus payload-shape assertions.
|
||||
|
||||
### Contract Implementation Note
|
||||
|
||||
- `contracts/provider-identity.openapi.yaml` is an internal action contract, not a promise of brand-new standalone HTTP controllers for every operation.
|
||||
- Existing Filament or Livewire action handlers may satisfy the contract as long as they preserve the documented authorization, confirmation, request-shape, audit, and response semantics.
|
||||
- Implementation should prefer adapting existing authorized surfaces over adding new standalone routes. New transport endpoints should be introduced only if an existing surface cannot faithfully satisfy the contract semantics.
|
||||
|
||||
### Deployment Sequencing Note
|
||||
|
||||
- Schema migrations for this feature must remain schema-only and must not execute classification or data backfill logic inline.
|
||||
- Legacy classification and audited backfill must run only after deploy via the explicit classification command or an equivalent queued operational path.
|
||||
- Release validation must confirm that T042 and T043 execute as post-deploy operational work, not as migration side effects.
|
||||
83
specs/137-platform-provider-identity/quickstart.md
Normal file
83
specs/137-platform-provider-identity/quickstart.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Quickstart: Platform Provider Identity Standardization
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate the platform-default Microsoft provider flow, the dedicated override flow, and the legacy classification safeguards after implementation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Sail services are running.
|
||||
2. The central Graph platform identity is configured in environment or secret-backed config.
|
||||
3. A workspace owner or similarly authorized user exists.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
```bash
|
||||
vendor/bin/sail up -d
|
||||
vendor/bin/sail artisan migrate --no-interaction
|
||||
```
|
||||
|
||||
Deployment sequencing rule:
|
||||
|
||||
- Run schema migrations first.
|
||||
- Execute legacy classification only after deploy through the explicit classification command or equivalent queued operational path.
|
||||
- Do not embed classification or backfill behavior inside schema migrations.
|
||||
|
||||
## Scenario 1: Standard platform onboarding
|
||||
|
||||
1. Open the managed tenant onboarding wizard or provider connection create flow for a tenant.
|
||||
2. Start a new Microsoft connection.
|
||||
3. Confirm the form shows the platform app ID read-only and does not ask for client ID or client secret.
|
||||
4. Trigger Grant admin consent.
|
||||
5. Complete the callback.
|
||||
6. Run verification.
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- A `provider_connections` row exists with `connection_type=platform`.
|
||||
- No `provider_credentials` row is required.
|
||||
- Consent and verification are visible separately.
|
||||
|
||||
## Scenario 2: Dedicated override
|
||||
|
||||
1. Sign in as a user who has the dedicated-management capability.
|
||||
2. Open the advanced provider connection management path.
|
||||
3. Enable dedicated mode explicitly.
|
||||
4. Enter dedicated credential details.
|
||||
5. Trigger consent and then verification.
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- The connection remains explicitly marked `dedicated`.
|
||||
- A `provider_credentials` row exists with dedicated metadata.
|
||||
- Runtime uses the dedicated identity only.
|
||||
|
||||
## Scenario 3: Legacy migration classification
|
||||
|
||||
1. Seed or identify one platform-like connection, one true dedicated connection, and one hybrid contradictory connection.
|
||||
2. Run the explicit post-deploy migration classifier path.
|
||||
3. Inspect the resulting connection metadata and audit logs.
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- Platform-like record is classified as `platform`.
|
||||
- Dedicated record is classified as `dedicated`.
|
||||
- Hybrid record is marked review-required and not silently auto-converted.
|
||||
|
||||
## Focused Test Commands
|
||||
|
||||
Run the minimum relevant suites first:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Providers
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Onboarding
|
||||
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
If those pass and broader confidence is needed:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact
|
||||
```
|
||||
79
specs/137-platform-provider-identity/research.md
Normal file
79
specs/137-platform-provider-identity/research.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Research: Platform Provider Identity Standardization
|
||||
|
||||
## Decision 1: Use one centrally managed platform identity source
|
||||
|
||||
- **Decision**: Add a `PlatformProviderIdentityResolver` that resolves the Microsoft platform identity from centralized platform configuration, initially backed by `config('graph.client_id')`, `config('graph.client_secret')`, `config('graph.tenant_id')`, and canonical redirect metadata.
|
||||
- **Rationale**: The repo already treats Graph credentials as central application configuration in [config/graph.php](config/graph.php). A dedicated resolver isolates secret storage details and gives both consent generation and runtime one canonical source.
|
||||
- **Alternatives considered**:
|
||||
- Keep resolving platform identity from tenant or provider credential rows: rejected because it recreates the hybrid model.
|
||||
- Copy platform credentials into every `provider_connections` row: rejected because it breaks secret governance and rotation.
|
||||
|
||||
## Decision 2: Keep `connection_type` binary and model review-required separately
|
||||
|
||||
- **Decision**: Add `connection_type` with only `platform` and `dedicated`, and represent migration review state separately using migration metadata and audit events instead of a third runtime mode.
|
||||
- **Rationale**: The spec requires dedicated to be an explicit exception, not a fallback, and requires `connection_type` to remain non-null. A third persisted runtime type would leak migration state into the long-term operating model.
|
||||
- **Alternatives considered**:
|
||||
- Add `review_required` as a third `connection_type`: rejected because it complicates runtime resolution and weakens the binary platform-versus-dedicated rule.
|
||||
- Auto-classify every ambiguous legacy record to one of the two modes: rejected because it hides hybrid risk.
|
||||
|
||||
## Decision 3: Use string columns plus PHP-backed enums or constants, not a native DB enum
|
||||
|
||||
- **Decision**: Implement `connection_type`, `consent_status`, `verification_status`, `source`, and `credential_kind` as indexed string columns backed by application enums or canonical constants.
|
||||
- **Rationale**: Existing provider connection status fields are strings, and this repo already favors string-backed states over database enum types. This keeps migrations simpler across PostgreSQL and SQLite test environments.
|
||||
- **Alternatives considered**:
|
||||
- Native PostgreSQL enum columns: rejected due to migration friction and poorer local test portability.
|
||||
|
||||
## Decision 4: Centralize consent URL generation behind a factory
|
||||
|
||||
- **Decision**: Add an `AdminConsentUrlFactory` that receives a provider connection, target tenant, and callback route metadata and returns the full canonical admin-consent URL.
|
||||
- **Rationale**: Current consent generation is fragmented across [app/Http/Controllers/TenantOnboardingController.php](app/Http/Controllers/TenantOnboardingController.php), [app/Filament/Resources/TenantResource.php](app/Filament/Resources/TenantResource.php), and [app/Support/Links/RequiredPermissionsLinks.php](app/Support/Links/RequiredPermissionsLinks.php). A single factory removes client-ID drift.
|
||||
- **Alternatives considered**:
|
||||
- Keep `TenantResource::adminConsentUrl()` as the root implementation: rejected because it still reads legacy tenant fields first.
|
||||
- Generate consent URLs directly in each controller or page: rejected because it recreates divergence.
|
||||
|
||||
## Decision 5: Split consent and verification into separate state machines
|
||||
|
||||
- **Decision**: Add a new `consent_status` field and a separate `verification_status` field while keeping legacy `status` and `health_status` only as backward-compatible summaries projected from the richer state.
|
||||
- **Rationale**: Current code often treats `status=connected` as if consent and operational health are the same thing. The spec requires those to be independently visible.
|
||||
- **Alternatives considered**:
|
||||
- Reuse `status` alone for consent and readiness: rejected because it is the existing defect.
|
||||
- Replace `status` immediately everywhere: rejected because too many current flows and tests rely on it.
|
||||
|
||||
## Decision 6: Runtime resolution branches strictly on `connection_type`
|
||||
|
||||
- **Decision**: Add a `ProviderIdentityResolver` that chooses one of two paths only:
|
||||
- `platform` uses `PlatformProviderIdentityResolver`
|
||||
- `dedicated` uses `ProviderCredential`
|
||||
- **Rationale**: Current [app/Services/Providers/CredentialManager.php](app/Services/Providers/CredentialManager.php), [app/Services/Providers/ProviderGateway.php](app/Services/Providers/ProviderGateway.php), and [app/Services/Providers/ProviderConnectionResolver.php](app/Services/Providers/ProviderConnectionResolver.php) all assume dedicated credentials exist for every usable connection.
|
||||
- **Alternatives considered**:
|
||||
- Teach `CredentialManager` to silently fall back among multiple sources: rejected because the spec explicitly forbids silent fallback.
|
||||
|
||||
## Decision 7: Standard onboarding creates a platform connection first, then consent, then verification
|
||||
|
||||
- **Decision**: The standard wizard flow creates or updates a `platform` provider connection before consent, shows the platform app ID read-only, and never asks for client ID or client secret unless the operator intentionally enters dedicated override mode.
|
||||
- **Rationale**: This aligns the user journey with the desired SaaS model and with the existing verification flow, which already starts from a selected provider connection.
|
||||
- **Alternatives considered**:
|
||||
- Keep “new connection” in the wizard as a credential-entry step: rejected because it preserves the legacy onboarding burden.
|
||||
- Delay provider connection creation until after callback: rejected because the consent flow then has no explicit connection record to classify or audit.
|
||||
|
||||
## Decision 8: Migration classification is explicit and auditable
|
||||
|
||||
- **Decision**: Add a `ProviderConnectionClassifier` that classifies each existing Microsoft connection as platform, dedicated, or review-required using signals from tenant legacy fields, provider credential payload, and current consent/runtime behavior. Review-required outcomes emit audit events and keep the record out of silent automatic conversion.
|
||||
- **Rationale**: Existing code and historical specs already show legacy drift, especially around tenant app fields and provider credential payloads. Safe standardization requires explicit visibility.
|
||||
- **Alternatives considered**:
|
||||
- Bulk-convert every current default connection to platform: rejected because some customers intentionally use dedicated app registrations.
|
||||
- Bulk-convert every connection with a credential row to dedicated: rejected because some credential rows are just historical remnants.
|
||||
|
||||
## Decision 9: Reuse existing operation observability for verification
|
||||
|
||||
- **Decision**: Keep provider verification and health checks on the existing `provider.connection.check` `OperationRun` path, but update the blockers and report payloads to reflect consent-versus-verification separation and platform-versus-dedicated identity selection.
|
||||
- **Rationale**: Existing jobs and verification reporting are already wired into the operations model. The required change is identity resolution and reason-code clarity, not a new operations subsystem.
|
||||
- **Alternatives considered**:
|
||||
- Move verification inline into the callback or detail page: rejected because remote Graph work belongs in the existing operation model.
|
||||
|
||||
## Decision 10: Guard against legacy fallback with dedicated tests and static scans
|
||||
|
||||
- **Decision**: Add regression guards for platform runtime and consent paths so CI fails if code starts reading `tenants.app_client_id`, `tenants.app_client_secret`, or `ProviderCredential.payload` as silent fallbacks for platform connections.
|
||||
- **Rationale**: The repo already uses targeted architectural guards such as [tests/Feature/Guards/NoLegacyTenantGraphOptionsTest.php](tests/Feature/Guards/NoLegacyTenantGraphOptionsTest.php). This feature needs equivalent enforcement for the new identity standard.
|
||||
- **Alternatives considered**:
|
||||
- Rely on feature tests alone: rejected because a future fallback could appear in an uncaught call site.
|
||||
207
specs/137-platform-provider-identity/spec.md
Normal file
207
specs/137-platform-provider-identity/spec.md
Normal file
@ -0,0 +1,207 @@
|
||||
# Feature Specification: Platform Provider Identity Standardization
|
||||
|
||||
**Feature Branch**: `137-platform-provider-identity`
|
||||
**Created**: 2026-03-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 137 — Platform Provider Identity Standardization"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**:
|
||||
- Microsoft provider onboarding wizard and connection setup flows under workspace-admin and tenant-admin surfaces
|
||||
- Microsoft admin consent start and callback flow
|
||||
- Provider connection detail, verification, and re-consent screens
|
||||
- Advanced dedicated-override management screens for authorized operators
|
||||
- **Data Ownership**:
|
||||
- The canonical platform provider identity is platform-managed and centrally configured; it is not copied into tenant-owned records
|
||||
- `provider_connections` remain tenant-owned operational records describing tenant target, consent state, verification state, and connection type
|
||||
- `provider_credentials` remain tenant-owned only for explicit dedicated connections
|
||||
- Legacy tenant credential fields remain legacy inspection and migration inputs only until later cleanup
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required for all provider connection management flows
|
||||
- Tenant entitlement remains required before a tenant-scoped provider connection can be viewed, created, verified, or changed
|
||||
- Standard connection creation and consent retry require the normal provider-connection management capability
|
||||
- Dedicated override, dedicated credential management, and connection-type changes require a stronger dedicated-management capability and remain unavailable to unauthorized roles
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Self-service platform onboarding (Priority: P1)
|
||||
|
||||
As a tenant operator, I want the standard Microsoft onboarding flow to use the platform-managed app automatically so that I can connect my tenant by granting consent without entering application credentials.
|
||||
|
||||
**Why this priority**: This is the product-default path and directly removes the current onboarding fragility and support burden.
|
||||
|
||||
**Independent Test**: Can be fully tested by starting a new Microsoft connection from the onboarding wizard, confirming that the flow creates a platform connection, shows the platform app identity read-only, asks only for admin consent, and completes verification without storing per-connection credentials.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized operator starts a new Microsoft connection in the standard onboarding flow, **When** the connection is created, **Then** the connection type is set to platform and no manual credential fields are required.
|
||||
2. **Given** a platform connection is awaiting consent, **When** the operator opens the consent action, **Then** the generated consent link uses the canonical platform app identity.
|
||||
3. **Given** consent succeeds for a platform connection, **When** the operator runs verification, **Then** the connection can proceed without any tenant-specific credential record.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Runtime and consent stay on one identity (Priority: P1)
|
||||
|
||||
As a platform maintainer, I want consent generation and runtime credential resolution to use the same identity source for platform connections so that the product cannot drift into a hybrid app model.
|
||||
|
||||
**Why this priority**: The core defect is identity drift between consent and runtime. If this is not fixed centrally, onboarding and operations remain unreliable.
|
||||
|
||||
**Independent Test**: Can be fully tested by exercising consent-link generation, callback completion, runtime verification, and provider operations for a platform connection while asserting that the same effective app identity is used end to end and that tenant-local credential fallbacks are rejected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform connection exists, **When** any runtime component resolves credentials, **Then** it uses the centralized platform identity and does not read tenant-local credential sources.
|
||||
2. **Given** a platform connection has granted consent, **When** a provider operation or verification starts, **Then** the resolved effective app identity matches the identity used for consent.
|
||||
3. **Given** legacy tenant-local app fields or dedicated credential rows still exist, **When** a platform connection resolves identity, **Then** those legacy values do not become silent fallbacks.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Dedicated stays explicit and protected (Priority: P2)
|
||||
|
||||
As a workspace owner or specially authorized admin, I want dedicated customer-specific app registrations to remain possible as an advanced override so that legitimate enterprise exceptions are supported without becoming the normal path.
|
||||
|
||||
**Why this priority**: Dedicated connections are required for some enterprise cases, but they must be intentionally controlled to preserve the SaaS governance model.
|
||||
|
||||
**Independent Test**: Can be fully tested by enabling the dedicated override path for an authorized user, confirming that the UI clearly labels it as an exception, requires explicit selection, accepts dedicated credentials only there, and blocks unauthorized users from accessing the path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dedicated override is not enabled for the current user, **When** they use the standard onboarding flow, **Then** they cannot access dedicated credential entry or dedicated connection creation.
|
||||
2. **Given** an authorized operator intentionally selects dedicated mode, **When** they create or edit the connection, **Then** the connection stores dedicated credential metadata and runtime uses that dedicated identity only.
|
||||
3. **Given** a dedicated connection is missing required credentials, **When** runtime verification or provider operations start, **Then** the system blocks the action with an explicit dedicated-credential reason.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Existing hybrid tenants are migrated safely (Priority: P2)
|
||||
|
||||
As an operator responsible for existing customer tenants, I want legacy and hybrid provider connections to be classified explicitly so that existing environments can be standardized without silent breakage.
|
||||
|
||||
**Why this priority**: Existing connections may already work only because of contradictory sources. Safe migration requires visibility, not implicit rewrites.
|
||||
|
||||
**Independent Test**: Can be fully tested by running the classification path against representative platform-like, dedicated, and hybrid legacy connections and verifying that each receives the correct migration outcome and follow-up behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an existing connection already behaves like a platform connection, **When** migration classification runs, **Then** it is explicitly marked platform.
|
||||
2. **Given** an existing connection intentionally depends on customer-specific credentials, **When** migration classification runs, **Then** it is explicitly marked dedicated.
|
||||
3. **Given** an existing connection mixes consent and runtime identities, **When** migration classification runs, **Then** it is flagged for operator review instead of being silently converted.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A platform connection may have granted consent but still fail verification because permissions, service principal presence, or tenant reachability are broken.
|
||||
- A dedicated credential row may exist from legacy history for a connection that should now be platform and must not be auto-consumed.
|
||||
- A consent callback may arrive for a connection that was created in one state but has stale or contradictory legacy metadata.
|
||||
- An operator may retry consent after consent was revoked or after verification previously failed, and the UI must keep consent and verification states separate.
|
||||
- A hybrid legacy tenant may have tenant-local app fields and provider credential rows that disagree on app identity and cannot be auto-classified safely.
|
||||
- Platform secret rotation must not require tenant-by-tenant rewrites or produce tenant-scoped audit artifacts that imply secrets were copied.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes Microsoft provider identity resolution for onboarding, consent, verification, and runtime operations. All Microsoft Graph access continues to flow through the canonical Graph client path and existing contract registry entries; this spec does not permit any new direct Graph endpoint shortcuts. Provider connection creation, connection-type changes, consent initiation, dedicated credential lifecycle changes, and migration reclassification are write behaviors and therefore require server-side authorization, audit logging, and focused tests. Verification and preflight flows that already run as operational work must continue to respect tenant isolation, observable run identity, and explicit failure reason handling.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature does not require the admin consent callback itself to create a new long-running run record, but any verification, preflight, or provider-operation flows that create or reuse an `OperationRun` must keep the existing Ops-UX 3-surface contract intact: queued toast only, active progress only in the approved progress surfaces, and one terminal initiator-only DB notification. Any `OperationRun.status` or `OperationRun.outcome` transition remains service-owned through `OperationRunService`. Any updated verification summary metrics must keep using approved numeric summary keys only. System-initiated verification remains auditable through Monitoring without tenant-wide completion notifications. Regression coverage must guard that provider identity changes do not reintroduce direct `OperationRun` lifecycle writes or ad-hoc completion notifications.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin/...` and tenant-context `/admin/t/{tenant}/...` provider connection management flows. Cross-plane access to `/system` remains deny-as-not-found. Non-members or actors lacking tenant entitlement must receive deny-as-not-found for tenant-scoped provider connection reads, consent retries, verification starts, dedicated override access, and credential mutations. Members lacking the required capability must receive forbidden on execution. Authorization must remain server-side for connection creation, connection-type changes, consent retry, verification start, dedicated credential create or rotate, and migration overrides. Capability checks must use the canonical capability registry only. Global search must remain tenant-safe and must not reveal inaccessible provider connections. Destructive or security-sensitive actions such as deleting dedicated credentials or switching connection type away from dedicated must require confirmation. Tests must include both positive and negative authorization coverage, including stronger gating for dedicated mode.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** The Microsoft admin consent handshake may continue to perform synchronous outbound identity-provider exchanges on auth-oriented callback endpoints without creating an `OperationRun`. This exception applies only to the consent handshake itself and must not be reused for Monitoring or other operational pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature introduces or clarifies operator-visible status semantics for connection type, consent status, verification status, and health status. Those labels and badge meanings must stay centralized so the same values render consistently across onboarding, detail views, tables, and verification surfaces. Coverage must confirm that new consent and verification states do not create ad-hoc badge mappings.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing copy must consistently use product vocabulary such as Platform connection, Dedicated connection, Grant admin consent, Run verification again, and Managed centrally by platform. The same terms must be preserved across wizard steps, detail headers, action labels, confirmation text, status messaging, notifications, and audit prose. Internal implementation terms such as resolver, fallback, or payload must not become primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament resources and pages for provider onboarding and connection management. The Action Surface Contract is satisfied. Provider connection list and detail surfaces keep inspection affordances, visible actions remain limited and grouped, empty states explain the platform-default path, and sensitive credential or connection-type mutations remain confirmation-gated and audited.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope Filament create and edit flows must present platform-default onboarding in structured sections, with the platform app identity displayed read-only and no manual credential fields in the standard path. Detail views must separate connection type, consent, verification, health, and effective app identity in sectioned presentation. Empty states must explain why no provider connection exists yet and offer a single primary CTA to start standard onboarding. Tables and detail pages must expose the core dimensions needed for support and operations without collapsing consent, verification, and health into one field.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-137-01 Platform default**: The system must default every newly created standard Microsoft provider connection to connection type platform.
|
||||
- **FR-137-02 Required connection type**: Every provider connection must store a non-null connection type with allowed values platform or dedicated.
|
||||
- **FR-137-03 Central platform identity source**: The system must resolve the platform Microsoft app identity from one canonical centrally managed source and must not duplicate the platform secret or app identity into tenant-owned connection or credential rows.
|
||||
- **FR-137-04 Canonical identity resolver**: The system must expose one canonical provider-identity resolution path that selects the effective identity strictly from connection type.
|
||||
- **FR-137-05 No platform fallback**: For platform connections, the resolver must not silently fall back to tenant-local app fields, provider credential payloads, or other legacy per-tenant identity sources.
|
||||
- **FR-137-06 Dedicated-only credential usage**: Dedicated credentials must be required and used only when the connection type is dedicated.
|
||||
- **FR-137-07 Canonical consent builder**: All Microsoft admin consent links must be generated from one canonical builder so that every surface uses the same app identity source for the same connection.
|
||||
- **FR-137-08 Platform consent identity**: For platform connections, consent links must always use the central platform app identity.
|
||||
- **FR-137-09 Dedicated consent identity**: For dedicated connections, consent links must use the explicitly associated dedicated app identity.
|
||||
- **FR-137-10 Standard onboarding UX**: The standard onboarding wizard must not ask operators to enter application credentials and must instead show the platform app identity as read-only along with consent and verification guidance.
|
||||
- **FR-137-11 Dedicated override UX**: Dedicated mode must appear only in an explicitly labeled advanced or enterprise override path and only for authorized users.
|
||||
- **FR-137-12 Callback state integrity**: A successful admin consent callback must create or update the provider connection with an explicit connection type and must not leave the connection appearing operational when the required identity source is missing or incomplete.
|
||||
- **FR-137-13 Separate state model**: The connection model must represent consent status separately from verification or health status so that granted consent does not automatically imply operational readiness.
|
||||
- **FR-137-14 Runtime identity parity**: Runtime verification, preflight, and provider operations must resolve the same effective app identity that the connection type implies.
|
||||
- **FR-137-15 Platform operational readiness**: A platform connection must be able to reach operational readiness without any per-connection credential row.
|
||||
- **FR-137-16 Dedicated operational readiness**: A dedicated connection must be blocked from operational readiness until its dedicated credential requirements are satisfied.
|
||||
- **FR-137-17 Legacy read restriction**: Legacy tenant-local app identity fields may be read only for migration analysis, operator review, or explicit dedicated reclassification and must not remain the standard source for new provider flows.
|
||||
- **FR-137-18 No implicit mode switch**: The system must never silently switch a connection from platform to dedicated or from one effective app identity to another as an error fallback.
|
||||
- **FR-137-19 Migration classification**: Existing connections must be explicitly classified into platform, dedicated, or review-required migration outcomes before final legacy cleanup.
|
||||
- **FR-137-20 Review-required protection**: Hybrid or contradictory legacy connections must surface a review-required outcome that blocks silent automatic conversion.
|
||||
- **FR-137-21 Audit coverage**: The system must audit provider connection creation, connection-type changes, consent start and result, consent revocation detection, verification result, dedicated credential lifecycle events, and migration classification outcomes.
|
||||
- **FR-137-22 Audit payload minimums**: Audit events for this feature must include workspace, tenant, provider, connection identifier, connection type, actor or system actor, prior and new state where applicable, and the triggering source or reason.
|
||||
- **FR-137-23 Dedicated authorization**: Dedicated mode enablement, dedicated credential management, and connection-type changes to or from dedicated must require a distinct dedicated-management capability that is stronger than standard platform connection management and is not implicitly granted wherever basic provider-management access exists.
|
||||
- **FR-137-24 Connection detail clarity**: Provider connection detail views must clearly display connection type, consent status, verification status, health status, effective app identity, and credential source.
|
||||
- **FR-137-25 Platform detail redaction**: Platform connection detail views must present the effective platform app identity as centrally managed and must never show a secret value.
|
||||
- **FR-137-26 Dedicated detail metadata**: Dedicated connection detail views must show dedicated credential source and relevant lifecycle metadata such as rotation timing or expiry when available.
|
||||
- **FR-137-27 Guard coverage**: Regression guards must fail if platform consent or runtime paths begin reading tenant-local app fields or dedicated credential payloads as silent fallbacks.
|
||||
- **FR-137-28 End-to-end parity tests**: The test suite must cover standard platform onboarding, dedicated override, callback behavior, runtime identity resolution, migration classification, and explicit negative cases for missing consent or missing dedicated credentials.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Removing all legacy tenant credential columns immediately
|
||||
- Migrating the platform identity from client secret to certificate in this spec
|
||||
- Designing a full sovereign or government-cloud provider model
|
||||
- Generalizing the provider identity model beyond Microsoft in this spec
|
||||
- Redesigning all customer self-service permission flows beyond what is needed for platform-default onboarding and dedicated override protection
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The product continues to operate as an enterprise SaaS platform with one canonical Microsoft platform app as the normal tenant-connection model.
|
||||
- Existing verification and provider-operation flows already exist and will be aligned to the new identity rules rather than replaced wholesale.
|
||||
- Some legacy tenant records may contain contradictory app identity data and therefore require operator review rather than automatic migration.
|
||||
- Platform secret storage may remain config-backed initially, with future movement to a central secret store without changing the tenant-facing model.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing provider connection onboarding, consent callback, verification, and provider-operation flows
|
||||
- Existing canonical Microsoft Graph client and contract registry path
|
||||
- Existing workspace and tenant authorization enforcement for provider connections
|
||||
- Existing audit logging infrastructure for connection and credential events
|
||||
- Existing operation observability for verification or preflight jobs where those flows already run asynchronously
|
||||
|
||||
### Contract Interpretation
|
||||
|
||||
- The OpenAPI artifact for this feature defines a logical internal action contract for provider-connection create, consent, verification, connection-type, and dedicated-credential mutations.
|
||||
- These contract operations may be satisfied by existing Filament or Livewire action handlers and existing controller endpoints; the spec does not require new standalone public web routes where an existing authorized surface already fulfills the contract semantics.
|
||||
- Destructive dedicated-credential deletion remains in scope as a first-class contract operation even when it is triggered from a Filament action rather than a new controller-only route.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Microsoft onboarding wizard | Workspace-admin and tenant-admin onboarding flow | Start connection | Step-based wizard flow | None | None | Start Microsoft connection | Re-enter wizard only where already supported | Continue and Cancel | Yes | Standard path shows platform app identity read-only and no credential inputs. |
|
||||
| Provider connection list | Provider connection resource list surfaces | New connection | Record click-through to detail | View, More | Existing grouped bulk actions only if already supported | Connect Microsoft tenant | View, Retry consent, Run verification, More | Edit only when allowed; standard path has no manual credential entry | Yes | Dedicated-only actions stay grouped and authorization-gated. |
|
||||
| Provider connection detail | Provider connection detail surface | Retry consent, Run verification | Detail sections | Edit, More | None | Not applicable | Retry consent, Run verification, Manage dedicated override where allowed | Save and Cancel on edit screens | Yes | Detail view must separate connection type, consent, verification, health, effective app ID, and credential source. |
|
||||
| Dedicated override management | Advanced or enterprise-only provider connection settings | Enable dedicated mode where allowed | Detail view and explicit entry point only | Manage credentials, More | None | Not applicable | Rotate credential, Delete credential, Revert connection type where allowed | Save and Cancel | Yes | Sensitive actions require confirmation and stronger dedicated-management authorization. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Platform Provider Identity**: The centrally managed Microsoft application identity that supplies the effective app ID, authority context, redirect configuration, and secret reference for standard platform connections.
|
||||
- **Provider Connection**: The tenant-owned record that represents a tenant’s Microsoft connection state, including connection type, consent status, verification status, and operational health.
|
||||
- **Provider Credential**: The tenant-owned credential record used only for explicit dedicated connections and never for the standard platform path.
|
||||
- **Connection Type**: The explicit mode selector that determines whether a connection uses the central platform identity or an approved dedicated customer-specific identity.
|
||||
- **Consent State**: The record of whether the customer tenant has granted, revoked, failed, or still requires admin consent.
|
||||
- **Verification State**: The record of whether technical validation and operational readiness checks have succeeded independently of consent.
|
||||
- **Migration Classification Outcome**: The explicit migration result that identifies an existing connection as platform, dedicated, or requiring operator review because of contradictory legacy identity sources.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-137-01 Standard onboarding simplification**: In focused onboarding coverage, 100% of new standard Microsoft connection flows complete without asking the operator for application credentials.
|
||||
- **SC-137-02 Identity parity**: In focused consent and runtime regression coverage, 100% of covered platform scenarios use the same effective app identity for consent generation and runtime resolution.
|
||||
- **SC-137-03 No silent fallback**: In focused negative coverage, 100% of covered platform scenarios reject tenant-local or dedicated credential fallback sources instead of silently using them.
|
||||
- **SC-137-04 Dedicated containment**: In focused authorization and UX coverage, 100% of covered dedicated scenarios require explicit opt-in and stronger authorization before dedicated credentials can be created or used.
|
||||
- **SC-137-05 State clarity**: In covered onboarding and detail-view scenarios, operators can distinguish consent status from verification or health status without relying on a single mixed status field.
|
||||
- **SC-137-06 Migration safety**: In focused migration coverage, 100% of representative existing connections are classified into platform, dedicated, or review-required outcomes without silent hybrid conversion.
|
||||
- **SC-137-07 Audit completeness**: In focused mutation coverage, every covered connection-type, consent, verification, credential, and migration change produces an auditable event with the required tenancy and actor context.
|
||||
255
specs/137-platform-provider-identity/tasks.md
Normal file
255
specs/137-platform-provider-identity/tasks.md
Normal file
@ -0,0 +1,255 @@
|
||||
# Tasks: Platform Provider Identity Standardization
|
||||
|
||||
**Input**: Design documents from `/specs/137-platform-provider-identity/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-identity.openapi.yaml`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime provider resolution, onboarding behavior, consent handling, authorization, and migration safeguards.
|
||||
**Operations**: Existing verification and provider-operation flows already create `OperationRun` records. Tasks below preserve that model and extend blocker handling and reporting without introducing a new operations surface.
|
||||
**RBAC**: This feature changes authorization around dedicated override and credential lifecycle management, so tasks include capability-registry changes, policy enforcement, confirmation requirements, and positive and negative authorization tests.
|
||||
**UI Naming**: Tasks include alignment for operator-facing labels such as `Platform connection`, `Dedicated connection`, `Grant admin consent`, and `Run verification again`.
|
||||
**Filament UI Action Surfaces**: This feature modifies Filament resources and onboarding pages, so tasks include action-surface updates, confirmation requirements, audit logging, and layout consistency work.
|
||||
**Badges**: Tasks include centralized badge updates for new consent and verification semantics.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently once foundational work is complete.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Establish shared constants, capabilities, and test fixtures that the rest of the feature depends on.
|
||||
|
||||
- [X] T001 [P] Add provider identity state constants and value objects in `app/Support/Providers/ProviderConnectionType.php`, `app/Support/Providers/ProviderConsentStatus.php`, `app/Support/Providers/ProviderVerificationStatus.php`, `app/Support/Providers/ProviderCredentialSource.php`, and `app/Support/Providers/ProviderCredentialKind.php`
|
||||
- [X] T002 [P] Add dedicated-management capability registry entries in `app/Support/Auth/Capabilities.php`, `app/Services/Auth/RoleCapabilityMap.php`, and `app/Services/Auth/WorkspaceRoleCapabilityMap.php`
|
||||
- [X] T003 [P] Update provider factories and shared test helpers for platform and dedicated defaults in `database/factories/ProviderConnectionFactory.php`, `database/factories/ProviderCredentialFactory.php`, and `tests/Pest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the schema and core resolver infrastructure required before any user story work can start.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Add provider identity state and migration-review fields to `provider_connections` in `database/migrations/2026_03_13_000001_add_provider_identity_fields_to_provider_connections.php` and dedicated credential lifecycle metadata to `provider_credentials` in `database/migrations/2026_03_13_000002_add_dedicated_metadata_to_provider_credentials.php`
|
||||
- [X] T005 [P] Update provider connection and credential model casts for new identity fields in `app/Models/ProviderConnection.php` and `app/Models/ProviderCredential.php`
|
||||
- [X] T006 [P] Create the central platform identity resolver in `app/Services/Providers/PlatformProviderIdentityResolver.php`
|
||||
- [X] T007 [P] Create the connection-type-aware identity resolver in `app/Services/Providers/ProviderIdentityResolver.php`
|
||||
- [X] T008 [P] Create the canonical consent URL factory and state projector in `app/Services/Providers/AdminConsentUrlFactory.php` and `app/Services/Providers/ProviderConnectionStateProjector.php`
|
||||
- [X] T009 [P] Create the migration classifier for legacy provider connections in `app/Services/Providers/ProviderConnectionClassifier.php`
|
||||
- [X] T010 [P] Extend shared provider reason codes and connection status semantics in `app/Support/Providers/ProviderReasonCodes.php` and `app/Support/Badges/BadgeCatalog.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. User stories can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Self-service platform onboarding (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Deliver a standard Microsoft onboarding flow that creates a platform connection by default, does not collect app credentials, and sends consent through the canonical platform app identity.
|
||||
|
||||
**Independent Test**: Start a new Microsoft connection from the onboarding wizard, confirm the UI shows the platform app ID read-only with no client credential inputs, complete the consent callback, and verify that the created connection is `platform` without a `provider_credentials` row.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Add platform-default onboarding feature coverage in `tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php`
|
||||
- [X] T012 [P] [US1] Add admin consent callback platform-state coverage in `tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Default new provider connections to platform mode in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php`
|
||||
- [X] T014 [US1] Remove standard client credential entry and show platform app metadata in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T015 [US1] Update provider connection create and detail schemas for platform-default UX in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- [X] T016 [US1] Route onboarding consent start through the canonical factory in `app/Http/Controllers/TenantOnboardingController.php` and `app/Support/Links/RequiredPermissionsLinks.php`
|
||||
- [X] T017 [US1] Persist explicit platform consent defaults and callback state transitions in `app/Http/Controllers/AdminConsentCallbackController.php` and `resources/views/admin-consent-callback.blade.php`
|
||||
- [X] T018 [US1] Update tenant-side consent entry points and next-step links to use platform terminology in `app/Filament/Resources/TenantResource.php` and `app/Support/Providers/ProviderNextStepsRegistry.php`
|
||||
- [X] T019 [US1] Add audited consent start and consent result events with required payload fields in `app/Http/Controllers/TenantOnboardingController.php`, `app/Http/Controllers/AdminConsentCallbackController.php`, and `tests/Feature/Audit/ProviderConnectionConsentAuditTest.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when standard onboarding creates a `platform` connection, shows no manual credential inputs, and drives consent through the canonical platform identity.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Runtime and consent stay on one identity (Priority: P1)
|
||||
|
||||
**Goal**: Ensure platform consent generation and runtime Graph resolution use the same central identity and never fall back silently to tenant-local or dedicated credentials.
|
||||
|
||||
**Independent Test**: Generate a consent URL for a platform connection, complete the callback, run verification, and assert that all runtime graph options resolve from the platform identity while tenant-local and dedicated credential fallbacks are blocked.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T020 [P] [US2] Add canonical consent URL unit coverage in `tests/Unit/Providers/AdminConsentUrlFactoryTest.php` and update `tests/Unit/TenantResourceConsentUrlTest.php`
|
||||
- [X] T021 [P] [US2] Add platform-versus-dedicated runtime resolver coverage in `tests/Unit/Providers/PlatformProviderIdentityResolverTest.php`, `tests/Unit/Providers/ProviderIdentityResolverTest.php`, `tests/Unit/Providers/CredentialManagerTest.php`, and `tests/Unit/Providers/ProviderGatewayTest.php`
|
||||
- [X] T022 [P] [US2] Add no-fallback architectural guard coverage in `tests/Feature/Guards/NoPlatformCredentialFallbackTest.php`
|
||||
- [X] T023 [P] [US2] Add verification-result and consent-revocation audit coverage with payload assertions in `tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php` and `tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T024 [US2] Refactor credential selection to branch strictly on `connection_type` in `app/Services/Providers/CredentialManager.php` and `app/Services/Providers/ProviderIdentityResolver.php`
|
||||
- [X] T025 [US2] Refactor Graph option construction to use resolved platform identity in `app/Services/Providers/ProviderGateway.php` and `app/Services/Providers/MicrosoftGraphOptionsResolver.php`
|
||||
- [X] T026 [US2] Separate consent blockers from credential blockers in `app/Services/Providers/ProviderConnectionResolver.php` and `app/Support/Providers/ProviderReasonCodes.php`
|
||||
- [X] T027 [US2] Replace legacy consent URL builders with `AdminConsentUrlFactory` in `app/Filament/Resources/TenantResource.php` and `app/Support/Links/RequiredPermissionsLinks.php`
|
||||
- [X] T028 [US2] Update verification and provider-operation start flows to consume resolved identity state and emit auditable verification-result and consent-revocation outcomes in `app/Jobs/ProviderConnectionHealthCheckJob.php`, `app/Services/Verification/StartVerification.php`, and `app/Services/Providers/ProviderConnectionStateProjector.php`
|
||||
- [X] T029 [US2] Cut over downstream Microsoft consumers to provider identity resolution in `app/Services/Intune/RbacOnboardingService.php`, `app/Services/Intune/RbacHealthService.php`, `app/Services/Intune/TenantConfigService.php`, `app/Services/Intune/PolicySyncService.php`, `app/Services/Intune/PolicySnapshotService.php`, `app/Services/Intune/RestoreService.php`, `app/Services/Inventory/InventorySyncService.php`, and `app/Services/Graph/ScopeTagResolver.php`
|
||||
- [X] T030 [US2] Update verification reports and remediation messaging for platform and dedicated reason codes in `app/Support/Verification/BlockedVerificationReportFactory.php`, `app/Support/Verification/TenantPermissionCheckClusters.php`, and `app/Support/Verification/VerificationReportSanitizer.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when platform consent and runtime use the same effective app identity and no silent fallback path remains.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Dedicated stays explicit and protected (Priority: P2)
|
||||
|
||||
**Goal**: Keep dedicated connections available only through an explicit, strongly authorized override path with audited and confirmed credential lifecycle actions.
|
||||
|
||||
**Independent Test**: Verify that unauthorized users cannot access dedicated override or credential management, while authorized users can explicitly enable dedicated mode, manage dedicated credentials, and run verification against that dedicated identity only.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T031 [P] [US3] Add dedicated override authorization coverage in `tests/Feature/ProviderConnections/ProviderConnectionDedicatedAuthorizationTest.php` and `tests/Unit/Policies/ProviderConnectionPolicyDedicatedTest.php`
|
||||
- [X] T032 [P] [US3] Add dedicated onboarding and inline-edit Livewire coverage in `tests/Feature/ManagedTenantOnboardingWizardTest.php` and `tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T033 [US3] Enforce a distinct dedicated-management capability boundary in `app/Support/Auth/Capabilities.php`, `app/Services/Auth/RoleCapabilityMap.php`, `app/Services/Auth/WorkspaceRoleCapabilityMap.php`, and `app/Policies/ProviderConnectionPolicy.php`
|
||||
- [X] T034 [US3] Add connection-type and dedicated credential actions that satisfy the internal action contract through existing authorized Filament or Livewire surfaces, with confirmation and grouped actions, in `app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- [X] T035 [US3] Update provider connection edit and view pages for dedicated-only credential management, including delete support, in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- [X] T036 [US3] Gate dedicated override UI and inline-edit paths in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [X] T037 [US3] Add audited connection-type and dedicated credential lifecycle logging in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`, `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `app/Observers/ProviderCredentialObserver.php`
|
||||
- [X] T038 [US3] Align provider connection empty states, labels, and helper copy to platform-versus-dedicated vocabulary in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when dedicated mode is explicit, audited, confirmed for destructive actions, and unavailable without the stronger capability.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Existing hybrid tenants are migrated safely (Priority: P2)
|
||||
|
||||
**Goal**: Classify existing provider connections into platform, dedicated, or review-required outcomes without silent hybrid conversion.
|
||||
|
||||
**Independent Test**: Run the classifier against representative platform-like, dedicated, and contradictory legacy records and confirm each record receives the expected classification, audit trail, and review-required handling.
|
||||
|
||||
**Execution Rule**: T042 and T043 are post-deploy operational tasks. They must not be embedded in schema migrations or hidden inside deploy-time migration side effects.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T039 [P] [US4] Add migration classifier unit coverage in `tests/Unit/Providers/ProviderConnectionClassifierTest.php`
|
||||
- [X] T040 [P] [US4] Add migration and legacy-source regression coverage in `tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php` and `tests/Feature/Guards/NoLegacyTenantProviderFallbackTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T041 [US4] Implement review-required classification metadata and result projection in `app/Services/Providers/ProviderConnectionClassifier.php` and `app/Models/ProviderConnection.php`
|
||||
- [X] T042 [US4] Add an explicit provider connection classification command for post-deploy execution in `app/Console/Commands/ClassifyProviderConnections.php`
|
||||
- [X] T043 [US4] Add audited post-migration classification backfill orchestration in `app/Console/Commands/ClassifyProviderConnections.php` and `tests/Feature/Audit/ProviderConnectionMigrationAuditTest.php`
|
||||
- [X] T044 [US4] Block new standard reads and writes to legacy tenant app credential fields in `app/Models/Tenant.php`, `app/Filament/Resources/TenantResource.php`, and `app/Http/Controllers/TenantOnboardingController.php`
|
||||
- [X] T045 [US4] Surface migration review state and effective app metadata in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Support/Providers/ProviderNextStepsRegistry.php`
|
||||
|
||||
**Checkpoint**: User Story 4 is complete when existing records can be classified safely and contradictory legacy configurations are visible instead of silently normalized.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish centralized status rendering, audit regression coverage, and final validation across the completed stories.
|
||||
|
||||
- [X] T046 [P] Update centralized provider badge mappings for new consent and verification semantics in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`, and `app/Support/Badges/Domains/ManagedTenantOnboardingVerificationStatusBadge.php`
|
||||
- [X] T047 [P] Add consolidated audit payload and badge regression coverage in `tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`
|
||||
- [X] T048 Validate the focused quickstart scenarios and targeted suites from `specs/137-platform-provider-identity/quickstart.md` using `tests/Unit/Providers`, `tests/Feature/Onboarding`, `tests/Feature/ProviderConnections`, and `tests/Feature/Guards` as a validation-only quality gate
|
||||
- [X] T049 Run formatting and final cleanup with `vendor/bin/sail bin pint --dirty --format agent` and verify `specs/137-platform-provider-identity/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can run in parallel with User Story 1 if staffed, but is recommended immediately after or alongside US1 because verification uses the shared identity resolver.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Story 2’s resolver cutover.
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the new runtime rules are defined so classification matches the final model.
|
||||
- **Polish (Phase 7)**: Depends on the desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Independent after Foundational; delivers the platform-default onboarding path.
|
||||
- **US2**: Independent after Foundational; delivers central identity parity and fallback removal.
|
||||
- **US3**: Independent after Foundational but assumes the new `connection_type` model and resolver behavior exist.
|
||||
- **US4**: Independent after Foundational but should be applied after the target model is stable.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write tests first and confirm they fail before implementation.
|
||||
- Land state or contract updates before UI and downstream integrations.
|
||||
- Complete the story-specific authorization and audit work before marking the story done.
|
||||
- Preserve the internal action-contract interpretation during implementation: prefer existing authorized surfaces over adding new standalone routes unless a documented gap requires one.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T003 can run in parallel.
|
||||
- T005-T010 can run in parallel once T004 is defined.
|
||||
- Within each user story, test tasks marked `[P]` can run in parallel.
|
||||
- US1 and US2 can be developed in parallel after Phase 2 if different engineers own onboarding versus runtime cutover.
|
||||
- US3 and US4 can also proceed in parallel once the core identity model is stable, but T042-T043 must remain post-deploy operational work rather than schema-migration work.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Write the two US1 tests together:
|
||||
T011 Add platform-default onboarding feature coverage in tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php
|
||||
T012 Add admin consent callback platform-state coverage in tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php
|
||||
|
||||
# Then split the UX and callback implementation work:
|
||||
T014 Remove standard client credential entry and show platform app metadata in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
T017 Persist explicit platform consent defaults and callback state transitions in app/Http/Controllers/AdminConsentCallbackController.php and resources/views/admin-consent-callback.blade.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Resolver and guard tests can be written in parallel:
|
||||
T020 Add canonical consent URL unit coverage in tests/Unit/Providers/AdminConsentUrlFactoryTest.php and tests/Unit/TenantResourceConsentUrlTest.php
|
||||
T021 Add platform-versus-dedicated runtime resolver coverage in tests/Unit/Providers/PlatformProviderIdentityResolverTest.php and related unit files
|
||||
T022 Add no-fallback architectural guard coverage in tests/Feature/Guards/NoPlatformCredentialFallbackTest.php
|
||||
T023 Add verification-result and consent-revocation audit coverage in tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php and tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php
|
||||
|
||||
# Runtime refactors can then be split by layer:
|
||||
T024 Refactor credential selection in app/Services/Providers/CredentialManager.php and app/Services/Providers/ProviderIdentityResolver.php
|
||||
T029 Cut over downstream Microsoft consumers in app/Services/Intune/*, app/Services/Inventory/InventorySyncService.php, and app/Services/Graph/ScopeTagResolver.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Authorization and Livewire tests can be built together:
|
||||
T031 Add dedicated override authorization coverage in tests/Feature/ProviderConnections/ProviderConnectionDedicatedAuthorizationTest.php and tests/Unit/Policies/ProviderConnectionPolicyDedicatedTest.php
|
||||
T032 Add dedicated onboarding and inline-edit Livewire coverage in tests/Feature/ManagedTenantOnboardingWizardTest.php and tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php
|
||||
|
||||
# UI and policy work can then proceed in parallel:
|
||||
T033 Enforce a distinct dedicated-management capability boundary in app/Support/Auth/Capabilities.php and related capability maps
|
||||
T035 Update provider connection edit and view pages for dedicated-only credential management in app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php and app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Classification tests can be written in parallel:
|
||||
T039 Add migration classifier unit coverage in tests/Unit/Providers/ProviderConnectionClassifierTest.php
|
||||
T040 Add migration and legacy-source regression coverage in tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php and tests/Feature/Guards/NoLegacyTenantProviderFallbackTest.php
|
||||
|
||||
# Then split classifier and UI follow-up work:
|
||||
T041 Implement review-required classification metadata in app/Services/Providers/ProviderConnectionClassifier.php and app/Models/ProviderConnection.php
|
||||
T045 Surface migration review state and effective app metadata in app/Filament/Resources/ProviderConnectionResource.php and app/Support/Providers/ProviderNextStepsRegistry.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
Deliver **Setup + Foundational + User Story 1** first to expose a platform-default onboarding flow that no longer asks for credentials.
|
||||
|
||||
### Identity Integrity Second
|
||||
|
||||
Land **User Story 2** immediately after or in parallel so the new onboarding flow and runtime provider operations share the same identity rules before broad rollout.
|
||||
|
||||
### Enterprise Override and Migration Last
|
||||
|
||||
Finish with **User Story 3** and **User Story 4** to preserve the dedicated exception path and classify legacy tenants safely without weakening the new default model.
|
||||
85
tests/Feature/Audit/ProviderConnectionConsentAuditTest.php
Normal file
85
tests/Feature/Audit/ProviderConnectionConsentAuditTest.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('audits admin consent start with platform connection metadata', function (): void {
|
||||
config()->set('graph.client_id', 'platform-app-id');
|
||||
config()->set('graph.client_secret', 'platform-app-secret');
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-consent-start',
|
||||
'name' => 'Tenant Consent Start',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
])
|
||||
->get(route('admin.consent.start', ['tenant' => $tenant->tenant_id]))
|
||||
->assertRedirect();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->firstOrFail();
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.consent_started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('success')
|
||||
->and($log?->actor_id)->toBe((int) $user->getKey())
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('platform')
|
||||
->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-app-id');
|
||||
});
|
||||
|
||||
it('audits admin consent callback results with connection type and outcome metadata', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-consent-result',
|
||||
'name' => 'Tenant Consent Result',
|
||||
]);
|
||||
|
||||
$this->get(route('admin.consent.callback', [
|
||||
'tenant' => $tenant->tenant_id,
|
||||
'admin_consent' => 'true',
|
||||
]))->assertOk();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->firstOrFail();
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.consent_result')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('success')
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('platform')
|
||||
->and($log?->metadata['consent_status'] ?? null)->toBe('granted')
|
||||
->and($log?->metadata['verification_status'] ?? null)->toBe('unknown');
|
||||
});
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('audits consent revocation detection when verification finds consent missing after it was previously granted', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
throw new RuntimeException(ProviderReasonCodes::ProviderConsentMissing);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'revoked-audit-tenant-id',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'revoked-audit-tenant-id',
|
||||
'is_default' => true,
|
||||
'consent_status' => 'granted',
|
||||
'verification_status' => 'healthy',
|
||||
'status' => 'connected',
|
||||
'health_status' => 'ok',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
|
||||
|
||||
$connection->refresh();
|
||||
$run->refresh();
|
||||
|
||||
expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked')
|
||||
->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked')
|
||||
->and($connection->status)->toBe('needs_consent')
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked);
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.consent_revoked')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('failed')
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('platform')
|
||||
->and($log?->metadata['previous_consent_status'] ?? null)->toBe('granted')
|
||||
->and($log?->metadata['consent_status'] ?? null)->toBe('revoked')
|
||||
->and($log?->metadata['verification_status'] ?? null)->toBe('blocked')
|
||||
->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing);
|
||||
});
|
||||
65
tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php
Normal file
65
tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps provider connection identity audit payloads aligned across consent and migration flows', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'identity-audit-tenant-id',
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('admin.consent.start', [
|
||||
'tenant' => $tenant->external_id,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$state = session('tenant_onboard_state');
|
||||
|
||||
$this->get(route('admin.consent.callback', [
|
||||
'tenant' => $tenant->tenant_id,
|
||||
'state' => $state,
|
||||
'admin_consent' => 'True',
|
||||
]))->assertSuccessful();
|
||||
|
||||
$this->artisan('tenantpilot:provider-connections:classify', ['--write' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$logs = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('action', [
|
||||
'provider_connection.consent_started',
|
||||
'provider_connection.consent_result',
|
||||
'provider_connection.migration_classification_applied',
|
||||
])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($logs)->toHaveCount(3);
|
||||
|
||||
foreach ($logs as $log) {
|
||||
expect($log->resource_type)->toBe('provider_connection')
|
||||
->and($log->resource_id)->not->toBeNull();
|
||||
|
||||
$metadata = is_array($log->metadata) ? $log->metadata : [];
|
||||
|
||||
expect($metadata)->toHaveKeys([
|
||||
'provider_connection_id',
|
||||
'provider',
|
||||
'connection_type',
|
||||
'source',
|
||||
]);
|
||||
}
|
||||
});
|
||||
74
tests/Feature/Audit/ProviderConnectionMigrationAuditTest.php
Normal file
74
tests/Feature/Audit/ProviderConnectionMigrationAuditTest.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('audits classification backfill orchestration and applied migration outcomes', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'audit-classification-tenant-id',
|
||||
'app_client_id' => 'legacy-tenant-client-id',
|
||||
'app_client_secret' => 'legacy-tenant-client-secret',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'audit-classification-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'source' => null,
|
||||
'payload' => [
|
||||
'client_id' => 'legacy-different-client-id',
|
||||
'client_secret' => 'legacy-different-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:provider-connections:classify', ['--write' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$started = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.migration_classification_started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$applied = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.migration_classification_applied')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($started)->not->toBeNull()
|
||||
->and($started?->status)->toBe('success')
|
||||
->and($started?->metadata)->toMatchArray([
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'candidate_count' => 1,
|
||||
'write' => true,
|
||||
]);
|
||||
|
||||
expect($applied)->not->toBeNull()
|
||||
->and($applied?->status)->toBe('success')
|
||||
->and($applied?->resource_type)->toBe('provider_connection')
|
||||
->and($applied?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($applied?->metadata)->toMatchArray([
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'connection_type' => 'dedicated',
|
||||
'migration_review_required' => true,
|
||||
]);
|
||||
});
|
||||
116
tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php
Normal file
116
tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('audits provider connection verification results with resolved identity metadata', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, data: ['id' => 'org-id', 'displayName' => 'Contoso']);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, data: ['permissions' => []]);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'verification-audit-tenant-id',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'verification-audit-tenant-id',
|
||||
'is_default' => true,
|
||||
'consent_status' => 'granted',
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.verification_result')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('success')
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('platform')
|
||||
->and($log?->metadata['consent_status'] ?? null)->toBe('granted')
|
||||
->and($log?->metadata['verification_status'] ?? null)->toBe('healthy')
|
||||
->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-client-id')
|
||||
->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config')
|
||||
->and($log?->metadata['status'] ?? null)->toBe('connected')
|
||||
->and($log?->metadata['health_status'] ?? null)->toBe('ok');
|
||||
});
|
||||
@ -13,7 +13,7 @@
|
||||
it('Spec081 audits credential creation with stable action and no secret leakage', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
@ -49,7 +49,7 @@
|
||||
it('Spec081 audits client id updates as credentials_updated without leaking secrets', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
@ -87,7 +87,7 @@
|
||||
it('Spec081 audits secret rotation as credentials_rotated with redacted metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
@ -124,3 +124,39 @@
|
||||
->and($log?->metadata['redacted_fields'] ?? [])->toContain('client_secret')
|
||||
->and((string) json_encode($log?->metadata ?? []))->not->toContain($rotatedSecret);
|
||||
});
|
||||
|
||||
it('Spec081 audits dedicated credential deletion without leaking secrets', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$credential = ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'spec081-client-delete',
|
||||
'client_secret' => 'spec081-secret-delete',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$credential->delete();
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.credentials_deleted')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_id')
|
||||
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_secret')
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('dedicated')
|
||||
->and((string) json_encode($log?->metadata ?? []))->not->toContain('spec081-secret-delete');
|
||||
});
|
||||
|
||||
@ -54,12 +54,13 @@
|
||||
$table = $component->instance()->getTable();
|
||||
|
||||
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
||||
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
||||
expect($table->getColumn('migration_review_required'))->not->toBeNull();
|
||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||
expect(session()->get($component->instance()->getTableSearchSessionKey()))->toBe('Contoso');
|
||||
expect(session()->get($component->instance()->getTableSortSessionKey()))->toBe('display_name:desc');
|
||||
|
||||
|
||||
@ -168,8 +168,8 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
||||
expect($table->persistsSearchInSession())->toBeTrue();
|
||||
expect($table->persistsSortInSession())->toBeTrue();
|
||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||
expect($table->getEmptyStateDescription())->toBe('Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.');
|
||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
||||
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.');
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('provider')?->isToggleable())->toBeTrue();
|
||||
|
||||
@ -34,13 +34,13 @@
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'is_default' => true,
|
||||
'status' => 'enabled',
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
@ -100,7 +100,7 @@
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
@ -121,7 +121,7 @@
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run?->outcome)->toBe('blocked')
|
||||
->and($run?->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
|
||||
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||
|
||||
expect(VerificationReportSchema::isValidReport($run?->context['verification_report'] ?? []))->toBeTrue();
|
||||
|
||||
|
||||
56
tests/Feature/Guards/NoLegacyTenantProviderFallbackTest.php
Normal file
56
tests/Feature/Guards/NoLegacyTenantProviderFallbackTest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
it('blocks legacy tenant app fallback reads in standard provider surfaces', function (): void {
|
||||
$root = base_path();
|
||||
$self = realpath(__FILE__);
|
||||
|
||||
$files = collect([
|
||||
'app/Filament/Resources/TenantResource.php',
|
||||
'app/Http/Controllers/TenantOnboardingController.php',
|
||||
'app/Services/Providers/AdminConsentUrlFactory.php',
|
||||
'app/Services/Providers/MicrosoftGraphOptionsResolver.php',
|
||||
'app/Services/Providers/ProviderConnectionResolver.php',
|
||||
'app/Services/Providers/ProviderGateway.php',
|
||||
'app/Services/Providers/ProviderIdentityResolver.php',
|
||||
])
|
||||
->map(fn (string $relative): string => $root.'/'.$relative)
|
||||
->filter(fn (string $absolute): bool => is_file($absolute))
|
||||
->values();
|
||||
|
||||
$patterns = [
|
||||
'/->app_client_id\b/',
|
||||
'/->app_client_secret\b/',
|
||||
];
|
||||
|
||||
$hits = [];
|
||||
|
||||
/** @var Collection<int, string> $files */
|
||||
foreach ($files as $path) {
|
||||
if ($self && realpath($path) === $self) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace($root.'/', '', $path);
|
||||
$lines = preg_split('/\R/', $contents) ?: [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach ($lines as $index => $line) {
|
||||
if (preg_match($pattern, $line) === 1) {
|
||||
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($hits)->toBeEmpty("Legacy tenant provider fallback detected:\n".implode("\n", $hits));
|
||||
});
|
||||
104
tests/Feature/Guards/NoPlatformCredentialFallbackTest.php
Normal file
104
tests/Feature/Guards/NoPlatformCredentialFallbackTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('prevents platform consent and runtime builders from reading legacy tenant credential fields', function (): void {
|
||||
$root = base_path();
|
||||
|
||||
$files = [
|
||||
'app/Filament/Resources/TenantResource.php',
|
||||
'app/Support/Links/RequiredPermissionsLinks.php',
|
||||
'app/Services/Providers/AdminConsentUrlFactory.php',
|
||||
'app/Services/Providers/ProviderGateway.php',
|
||||
'app/Services/Providers/MicrosoftGraphOptionsResolver.php',
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'/->app_client_id\b/',
|
||||
'/->app_client_secret\b/',
|
||||
];
|
||||
|
||||
$hits = [];
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
$absolutePath = $root.'/'.$relativePath;
|
||||
|
||||
if (! is_file($absolutePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($absolutePath);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($relativePath === 'app/Filament/Resources/TenantResource.php') {
|
||||
preg_match('/public static function adminConsentUrl\(Tenant \$tenant\): \?string\s*\{(?P<body>.*?)^ \}/ms', $contents, $matches);
|
||||
$contents = is_string($matches['body'] ?? null) ? (string) $matches['body'] : '';
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $contents) ?: [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach ($lines as $index => $line) {
|
||||
if (preg_match($pattern, $line) === 1) {
|
||||
$hits[] = $relativePath.':'.($index + 1).' -> '.trim($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($hits)->toBeEmpty("Legacy tenant credential reads detected in platform builders:\n".implode("\n", $hits));
|
||||
});
|
||||
|
||||
it('prevents platform consent and runtime builders from reading provider credential payloads directly', function (): void {
|
||||
$root = base_path();
|
||||
|
||||
$files = [
|
||||
'app/Filament/Resources/TenantResource.php',
|
||||
'app/Support/Links/RequiredPermissionsLinks.php',
|
||||
'app/Services/Providers/AdminConsentUrlFactory.php',
|
||||
'app/Services/Providers/ProviderConnectionResolver.php',
|
||||
'app/Services/Providers/ProviderGateway.php',
|
||||
'app/Services/Providers/MicrosoftGraphOptionsResolver.php',
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'/->credential\b/',
|
||||
'/->payload\b/',
|
||||
];
|
||||
|
||||
$hits = [];
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
$absolutePath = $root.'/'.$relativePath;
|
||||
|
||||
if (! is_file($absolutePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($absolutePath);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($relativePath === 'app/Filament/Resources/TenantResource.php') {
|
||||
preg_match('/public static function adminConsentUrl\(Tenant \$tenant\): \?string\s*\{(?P<body>.*?)^ \}/ms', $contents, $matches);
|
||||
$contents = is_string($matches['body'] ?? null) ? (string) $matches['body'] : '';
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $contents) ?: [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach ($lines as $index => $line) {
|
||||
if (preg_match($pattern, $line) === 1) {
|
||||
$hits[] = $relativePath.':'.($index + 1).' -> '.trim($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($hits)->toBeEmpty("Provider credential payload fallback detected in platform builders:\n".implode("\n", $hits));
|
||||
});
|
||||
@ -125,7 +125,6 @@
|
||||
$allowlist = [
|
||||
// NOTE: Shrink this list while finishing Spec081 service cutovers.
|
||||
'app/Models/Tenant.php',
|
||||
'app/Filament/Resources/TenantResource.php',
|
||||
'app/Services/Intune/TenantConfigService.php',
|
||||
'app/Services/Intune/TenantPermissionService.php',
|
||||
'app/Console/Commands/ReclassifyEnrollmentConfigurations.php',
|
||||
@ -282,6 +281,7 @@
|
||||
$allowlist = [
|
||||
'app/Services/Providers/CredentialManager.php',
|
||||
'app/Services/Providers/ProviderGateway.php',
|
||||
'app/Services/Providers/ProviderIdentityResolver.php',
|
||||
];
|
||||
|
||||
$forbiddenPattern = '/->getClientCredentials\(/';
|
||||
|
||||
@ -3,11 +3,17 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -97,6 +103,104 @@
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the Entra tenant id placeholder for onboarding input guidance', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
|
||||
});
|
||||
|
||||
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'abababab-abab-abab-abab-abababababab';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Activation Ready Tenant',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Platform onboarding connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Activation Ready Tenant',
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Consent is ready.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'bootstrap',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Skipped - No bootstrap actions selected')
|
||||
->assertSee('Tenant status will be set to Active.')
|
||||
->assertSee('The provider connection will be used for all Graph API calls.');
|
||||
});
|
||||
|
||||
it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -116,6 +220,86 @@
|
||||
->assertSet('data.override_reason', '');
|
||||
});
|
||||
|
||||
it('allows workspace owners to create a dedicated override connection explicitly during onboarding', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '55555555-5555-5555-5555-555555555555';
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Dedicated Tenant',
|
||||
]);
|
||||
|
||||
$component
|
||||
->set('data.connection_mode', 'new')
|
||||
->assertSee('Dedicated override')
|
||||
->call('createProviderConnection', [
|
||||
'display_name' => 'Dedicated onboarding connection',
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => 'dedicated-onboarding-client',
|
||||
'client_secret' => 'dedicated-onboarding-secret',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
|
||||
$connection = \App\Models\ProviderConnection::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->firstOrFail();
|
||||
|
||||
expect($connection->connection_type)->toBe(ProviderConnectionType::Dedicated)
|
||||
->and($connection->credential)->not->toBeNull()
|
||||
->and($connection->credential?->payload['client_id'] ?? null)->toBe('dedicated-onboarding-client');
|
||||
});
|
||||
|
||||
it('forbids workspace managers from creating dedicated override connections during onboarding', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '66666666-6666-6666-6666-666666666666';
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Managed Tenant',
|
||||
]);
|
||||
|
||||
$component
|
||||
->assertDontSee('Dedicated override')
|
||||
->call('createProviderConnection', [
|
||||
'display_name' => 'Forbidden dedicated connection',
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => 'forbidden-dedicated-client',
|
||||
'client_secret' => 'forbidden-dedicated-secret',
|
||||
'is_default' => true,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('is idempotent when identifying the same Entra tenant ID twice', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('stores a successful callback as a platform connection with separate consent and verification states', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-platform-ok',
|
||||
'name' => 'Contoso Platform',
|
||||
]);
|
||||
|
||||
$this->get(route('admin.consent.callback', [
|
||||
'tenant' => $tenant->tenant_id,
|
||||
'admin_consent' => 'true',
|
||||
]))->assertOk();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $tenant->graphTenantId())
|
||||
->firstOrFail();
|
||||
|
||||
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform)
|
||||
->and($connection->status)->toBe('connected')
|
||||
->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted)
|
||||
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
|
||||
->and($connection->credential()->exists())->toBeFalse()
|
||||
->and($connection->last_error_reason_code)->toBeNull();
|
||||
});
|
||||
|
||||
it('stores callback failures without promoting the platform connection to a verified state', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-platform-error',
|
||||
'name' => 'Fabrikam Platform',
|
||||
]);
|
||||
|
||||
$this->get(route('admin.consent.callback', [
|
||||
'tenant' => $tenant->tenant_id,
|
||||
'error' => 'access_denied',
|
||||
]))->assertOk();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $tenant->graphTenantId())
|
||||
->firstOrFail();
|
||||
|
||||
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform)
|
||||
->and($connection->status)->toBe('error')
|
||||
->and($connection->consent_status)->toBe(ProviderConsentStatus::Failed)
|
||||
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
|
||||
->and($connection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)
|
||||
->and($connection->credential()->exists())->toBeFalse();
|
||||
});
|
||||
@ -5,12 +5,15 @@
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -39,27 +42,49 @@
|
||||
'name' => 'Acme',
|
||||
]);
|
||||
|
||||
$component->call('createProviderConnection', [
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Acme connection',
|
||||
'client_id' => '00000000-0000-0000-0000-000000000000',
|
||||
'client_secret' => 'super-secret',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$component->call('startVerification');
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->firstOrFail();
|
||||
|
||||
$run->update([
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$session->forceFill([
|
||||
'state' => array_merge($session->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$component->set('onboardingSession', $session->fresh());
|
||||
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
|
||||
|
||||
$component
|
||||
->call('completeOnboarding')
|
||||
->assertStatus(403);
|
||||
@ -132,3 +157,98 @@
|
||||
->where('action', 'managed_tenant_onboarding.activation')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('requires an override when the stored verification report is blocked even if the run outcome says succeeded', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '12121212-3434-5656-7878-909090909090';
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Blocked By Report',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Blocked by report connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'permission_check',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$session->forceFill([
|
||||
'state' => array_merge($session->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$component->set('onboardingSession', $session->fresh());
|
||||
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
|
||||
|
||||
$component->call('completeOnboarding');
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->status)->not->toBe(Tenant::STATUS_ACTIVE);
|
||||
|
||||
$component
|
||||
->set('data.override_blocked', true)
|
||||
->set('data.override_reason', 'Owner approved temporary exception')
|
||||
->call('completeOnboarding');
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->status)->toBe(Tenant::STATUS_ACTIVE);
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -33,7 +34,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
@ -85,7 +86,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
@ -139,7 +140,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
@ -235,7 +236,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
@ -274,7 +275,7 @@
|
||||
'client_secret' => '',
|
||||
])
|
||||
->assertHasErrors([
|
||||
'client_secret' => 'Enter a new client secret when changing the App (client) ID.',
|
||||
'client_secret' => 'Enter a dedicated client secret when enabling dedicated mode or changing the App (client) ID.',
|
||||
]);
|
||||
|
||||
$connection->refresh();
|
||||
@ -301,7 +302,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
@ -363,6 +364,116 @@
|
||||
expect($encodedMetadata)->not->toContain($newSecret);
|
||||
});
|
||||
|
||||
it('allows workspace owners to enable a dedicated override inline for the selected connection', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '88888888-1111-1111-1111-111111111111',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Platform connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||
'display_name' => 'Dedicated connection',
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => 'inline-dedicated-client',
|
||||
'client_secret' => 'inline-dedicated-secret',
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$connection->refresh();
|
||||
|
||||
expect($connection->connection_type)->toBe(ProviderConnectionType::Dedicated)
|
||||
->and($connection->credential)->not->toBeNull()
|
||||
->and($connection->credential?->payload['client_id'] ?? null)->toBe('inline-dedicated-client');
|
||||
});
|
||||
|
||||
it('returns 403 when workspace managers try to enable a dedicated override inline', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '99999999-1111-1111-1111-111111111111',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Platform connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||
'display_name' => 'Dedicated connection',
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => 'forbidden-inline-client',
|
||||
'client_secret' => 'forbidden-inline-secret',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('returns 404 when attempting to inline-edit a connection belonging to a different tenant', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -387,7 +498,7 @@
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $otherTenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('creates platform connections by default during onboarding without dedicated credentials', function (): void {
|
||||
config()->set('graph.client_id', 'platform-app-id');
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$entraTenantId = '77777777-7777-7777-7777-777777777777';
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Acme Platform',
|
||||
]);
|
||||
|
||||
$component
|
||||
->set('data.connection_mode', 'new')
|
||||
->assertSee('Platform app ID')
|
||||
->assertSee('Managed centrally by platform')
|
||||
->assertSet('data.new_connection.platform_app_id', 'platform-app-id')
|
||||
->assertDontSee('Client secret')
|
||||
->call('createProviderConnection', [
|
||||
'display_name' => 'Platform connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->firstOrFail();
|
||||
|
||||
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform)
|
||||
->and($connection->consent_status)->toBe(ProviderConsentStatus::Required)
|
||||
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
|
||||
->and($connection->credential()->exists())->toBeFalse();
|
||||
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereNull('completed_at')
|
||||
->firstOrFail();
|
||||
|
||||
expect($session->state['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($session->state)->not->toHaveKey('client_secret');
|
||||
});
|
||||
@ -52,7 +52,12 @@
|
||||
|
||||
ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update(['status' => 'connected']);
|
||||
->update([
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
'last_error_reason_code' => null,
|
||||
'last_error_message' => null,
|
||||
]);
|
||||
|
||||
$component->call('startVerification');
|
||||
$component->call('startVerification');
|
||||
@ -174,13 +179,24 @@
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Contoso platform connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
@ -207,6 +223,7 @@
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
@ -216,6 +233,7 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Status: Blocked')
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee('Graph permissions')
|
||||
->assertSee($entraTenantId);
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
it('Spec081 blocks provider operation starts when default connection has no credential', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
expect($result->status)->toBe('blocked')
|
||||
->and($result->run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
|
||||
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing)
|
||||
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||
});
|
||||
@ -63,7 +63,7 @@
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS provider_connections_default_unique');
|
||||
|
||||
$first = ProviderConnection::factory()->create([
|
||||
$first = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
@ -73,7 +73,7 @@
|
||||
'provider_connection_id' => (int) $first->getKey(),
|
||||
]);
|
||||
|
||||
$second = ProviderConnection::factory()->create([
|
||||
$second = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows dedicated override actions as enabled for tenant owners', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||
->assertActionVisible('enable_dedicated_override')
|
||||
->assertActionEnabled('enable_dedicated_override');
|
||||
});
|
||||
|
||||
it('shows dedicated override actions as disabled for tenant managers without the stronger capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||
->assertActionVisible('enable_dedicated_override')
|
||||
->assertActionDisabled('enable_dedicated_override')
|
||||
->assertActionExists('enable_dedicated_override', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::insufficientPermission();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the connection when a tenant manager attempts a dedicated override action', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||
->callAction('enable_dedicated_override', data: [
|
||||
'client_id' => 'manager-dedicated-client',
|
||||
'client_secret' => 'manager-dedicated-secret',
|
||||
]);
|
||||
|
||||
$connection->refresh();
|
||||
|
||||
expect($connection->connection_type)->toBe(\App\Support\Providers\ProviderConnectionType::Platform)
|
||||
->and($connection->credential)->toBeNull();
|
||||
});
|
||||
@ -46,11 +46,11 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'needs_consent',
|
||||
'status' => 'connected',
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
@ -103,6 +103,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
'connection_type' => 'dedicated',
|
||||
],
|
||||
]);
|
||||
|
||||
@ -112,6 +113,9 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
});
|
||||
|
||||
it('finalizes the verification run as blocked when admin consent is missing', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
@ -255,11 +259,11 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'needs_consent',
|
||||
'status' => 'connected',
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
@ -346,11 +350,11 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'needs_consent',
|
||||
'status' => 'connected',
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
@ -391,7 +395,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$connection->refresh();
|
||||
$run->refresh();
|
||||
|
||||
expect($connection->status)->toBe('needs_consent');
|
||||
expect($connection->status)->toBe('error');
|
||||
expect($connection->health_status)->toBe('down');
|
||||
expect($connection->last_error_reason_code)->toBe('provider_auth_failed');
|
||||
expect((string) $connection->last_error_message)
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
@ -29,15 +28,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'connected',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->callTableAction('check_connection', $connection);
|
||||
@ -78,15 +74,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'connected',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListProviderConnections::class);
|
||||
|
||||
@ -110,7 +103,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('classifies legacy provider connections through the explicit command without silent conversion', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
$platformTenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'platform-classification-tenant-id',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$platformConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'tenant_id' => (int) $platformTenant->getKey(),
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'platform-classification-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$dedicatedTenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'dedicated-classification-tenant-id',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$dedicatedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'tenant_id' => (int) $dedicatedTenant->getKey(),
|
||||
'workspace_id' => (int) $dedicatedTenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'dedicated-classification-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$dedicatedCredential = ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $dedicatedConnection->getKey(),
|
||||
'source' => null,
|
||||
'credential_kind' => null,
|
||||
'payload' => [
|
||||
'client_id' => 'legacy-dedicated-client-id',
|
||||
'client_secret' => 'legacy-dedicated-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$hybridTenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'hybrid-classification-tenant-id',
|
||||
'app_client_id' => 'legacy-tenant-client-id',
|
||||
'app_client_secret' => 'legacy-tenant-client-secret',
|
||||
]);
|
||||
|
||||
$hybridConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'tenant_id' => (int) $hybridTenant->getKey(),
|
||||
'workspace_id' => (int) $hybridTenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'hybrid-classification-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $hybridConnection->getKey(),
|
||||
'source' => null,
|
||||
'payload' => [
|
||||
'client_id' => 'legacy-different-client-id',
|
||||
'client_secret' => 'legacy-different-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:provider-connections:classify', ['--write' => true])
|
||||
->expectsOutputToContain('Applied classifications: 3')
|
||||
->expectsOutputToContain('Review required: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
$platformConnection->refresh();
|
||||
$dedicatedConnection->refresh();
|
||||
$hybridConnection->refresh();
|
||||
$dedicatedCredential->refresh();
|
||||
|
||||
expect($platformConnection->connection_type->value)->toBe('platform')
|
||||
->and($platformConnection->migration_review_required)->toBeFalse()
|
||||
->and($platformConnection->metadata)->toMatchArray([
|
||||
'legacy_identity_result' => 'platform',
|
||||
'effective_app' => [
|
||||
'app_id' => 'platform-client-id',
|
||||
'source' => 'platform_config',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($dedicatedConnection->connection_type->value)->toBe('dedicated')
|
||||
->and($dedicatedConnection->migration_review_required)->toBeFalse()
|
||||
->and($dedicatedConnection->metadata)->toMatchArray([
|
||||
'legacy_identity_result' => 'dedicated',
|
||||
'effective_app' => [
|
||||
'app_id' => 'legacy-dedicated-client-id',
|
||||
'source' => 'dedicated_credential',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($dedicatedCredential->source)->toBe(ProviderCredentialSource::LegacyMigrated)
|
||||
->and($dedicatedCredential->credential_kind)->toBe(ProviderCredentialKind::ClientSecret);
|
||||
|
||||
expect($hybridConnection->connection_type->value)->toBe('dedicated')
|
||||
->and($hybridConnection->migration_review_required)->toBeTrue()
|
||||
->and($hybridConnection->verification_status->value)->toBe('blocked')
|
||||
->and($hybridConnection->status)->toBe('error')
|
||||
->and($hybridConnection->health_status)->toBe('down')
|
||||
->and($hybridConnection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConnectionReviewRequired)
|
||||
->and($hybridConnection->metadata)->toMatchArray([
|
||||
'legacy_identity_review_required' => true,
|
||||
'legacy_identity_result' => 'dedicated',
|
||||
'effective_app' => [
|
||||
'app_id' => null,
|
||||
'source' => 'review_required',
|
||||
],
|
||||
]);
|
||||
});
|
||||
@ -16,6 +16,15 @@
|
||||
'display_name' => 'Spec081 Connection',
|
||||
'provider' => 'microsoft',
|
||||
'status' => 'connected',
|
||||
'migration_review_required' => true,
|
||||
'metadata' => [
|
||||
'legacy_identity_classification_source' => 'tenantpilot:provider-connections:classify',
|
||||
'legacy_identity_result' => 'dedicated',
|
||||
'effective_app' => [
|
||||
'app_id' => null,
|
||||
'source' => 'review_required',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -28,7 +37,9 @@
|
||||
|
||||
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Spec081 Connection');
|
||||
->assertSee('Spec081 Connection')
|
||||
->assertSee('Migration review')
|
||||
->assertSee('Review required');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
@ -39,8 +39,9 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'status' => 'connected',
|
||||
@ -40,7 +40,7 @@
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run?->outcome)->toBe('blocked')
|
||||
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
|
||||
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||
|
||||
$report = $run?->context['verification_report'] ?? null;
|
||||
expect($report)->toBeArray();
|
||||
@ -50,7 +50,7 @@
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
|
||||
$last = $notifications->last();
|
||||
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::ProviderCredentialMissing);
|
||||
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||
|
||||
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
|
||||
expect($labels)->toContain('Manage Provider Connections');
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
@ -30,15 +29,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'connected',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListProviderConnections::class);
|
||||
$component->callTableAction('inventory_sync', $connection);
|
||||
@ -86,15 +82,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'connected',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListProviderConnections::class);
|
||||
$component->callTableAction('compliance_snapshot', $connection);
|
||||
@ -133,15 +126,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'connected',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListProviderConnections::class);
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
it('builds graph options from default provider connection credentials', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
@ -66,7 +66,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
|
||||
@ -7,6 +7,11 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Pest\PendingCalls\TestCall;
|
||||
@ -244,6 +249,7 @@ function createUserWithTenant(
|
||||
string $role = 'owner',
|
||||
?string $workspaceRole = null,
|
||||
bool $ensureDefaultMicrosoftProviderConnection = true,
|
||||
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
|
||||
): array {
|
||||
$user ??= User::factory()->create();
|
||||
$tenant ??= Tenant::factory()->create();
|
||||
@ -288,7 +294,7 @@ function createUserWithTenant(
|
||||
]);
|
||||
|
||||
if ($ensureDefaultMicrosoftProviderConnection) {
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
|
||||
}
|
||||
|
||||
return [$user, $tenant];
|
||||
@ -302,8 +308,13 @@ function filamentTenantRouteParams(Tenant $tenant): array
|
||||
return ['tenant' => (string) $tenant->external_id];
|
||||
}
|
||||
|
||||
function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
||||
{
|
||||
function ensureDefaultProviderConnection(
|
||||
Tenant $tenant,
|
||||
string $provider = 'microsoft',
|
||||
string $connectionType = ProviderConnectionType::Dedicated->value,
|
||||
): ProviderConnection {
|
||||
$resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated;
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', $provider)
|
||||
@ -312,18 +323,37 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connectionFactory = ProviderConnection::factory();
|
||||
|
||||
if ($resolvedConnectionType === ProviderConnectionType::Dedicated) {
|
||||
$connectionFactory = $connectionFactory->dedicated();
|
||||
}
|
||||
|
||||
$connection = $connectionFactory->verifiedHealthy()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => $provider,
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? fake()->uuid()),
|
||||
'status' => 'connected',
|
||||
'health_status' => 'ok',
|
||||
'connection_type' => $resolvedConnectionType->value,
|
||||
'is_default' => true,
|
||||
]);
|
||||
} else {
|
||||
$entraTenantId = trim((string) $connection->entra_tenant_id);
|
||||
$currentConnectionType = $connection->connection_type instanceof ProviderConnectionType
|
||||
? $connection->connection_type->value
|
||||
: (is_string($connection->connection_type) ? $connection->connection_type : null);
|
||||
$currentConsentStatus = $connection->consent_status instanceof ProviderConsentStatus
|
||||
? $connection->consent_status->value
|
||||
: (is_string($connection->consent_status) ? $connection->consent_status : null);
|
||||
$currentVerificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
|
||||
? $connection->verification_status->value
|
||||
: (is_string($connection->verification_status) ? $connection->verification_status : null);
|
||||
|
||||
$updates = [];
|
||||
|
||||
if ($currentConnectionType !== $resolvedConnectionType->value) {
|
||||
$updates['connection_type'] = $resolvedConnectionType->value;
|
||||
}
|
||||
|
||||
if (! $connection->is_default) {
|
||||
$updates['is_default'] = true;
|
||||
}
|
||||
@ -332,6 +362,14 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
$updates['status'] = 'connected';
|
||||
}
|
||||
|
||||
if ($currentConsentStatus !== ProviderConsentStatus::Granted->value) {
|
||||
$updates['consent_status'] = ProviderConsentStatus::Granted->value;
|
||||
}
|
||||
|
||||
if ($currentVerificationStatus !== ProviderVerificationStatus::Healthy->value) {
|
||||
$updates['verification_status'] = ProviderVerificationStatus::Healthy->value;
|
||||
}
|
||||
|
||||
if ($connection->health_status !== 'ok') {
|
||||
$updates['health_status'] = 'ok';
|
||||
}
|
||||
@ -340,6 +378,22 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
$updates['entra_tenant_id'] = (string) ($tenant->tenant_id ?? fake()->uuid());
|
||||
}
|
||||
|
||||
if (! array_key_exists('consent_granted_at', $updates)) {
|
||||
$updates['consent_granted_at'] = now();
|
||||
}
|
||||
|
||||
if (! array_key_exists('consent_last_checked_at', $updates)) {
|
||||
$updates['consent_last_checked_at'] = now();
|
||||
}
|
||||
|
||||
if (! array_key_exists('last_health_check_at', $updates)) {
|
||||
$updates['last_health_check_at'] = now();
|
||||
}
|
||||
|
||||
if (! array_key_exists('migration_review_required', $updates)) {
|
||||
$updates['migration_review_required'] = false;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$connection->forceFill($updates)->save();
|
||||
$connection->refresh();
|
||||
@ -348,10 +402,23 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
|
||||
$credential = $connection->credential()->first();
|
||||
|
||||
if ($resolvedConnectionType === ProviderConnectionType::Platform) {
|
||||
if ($credential instanceof ProviderCredential) {
|
||||
$credential->delete();
|
||||
$connection->refresh();
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => 'client_secret',
|
||||
'type' => ProviderCredentialKind::ClientSecret->value,
|
||||
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
|
||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
||||
'last_rotated_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'payload' => [
|
||||
'client_id' => fake()->uuid(),
|
||||
'client_secret' => fake()->sha1(),
|
||||
@ -364,6 +431,16 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
return $connection;
|
||||
}
|
||||
|
||||
function ensureDefaultPlatformProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
||||
{
|
||||
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Platform->value);
|
||||
}
|
||||
|
||||
function ensureDefaultDedicatedProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
||||
{
|
||||
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Dedicated->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows tenant owners to manage dedicated overrides for accessible provider connections', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$result = app(ProviderConnectionPolicy::class)->manageDedicated($user, $connection);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('forbids tenant managers from dedicated override actions while still allowing standard updates', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$policy = app(ProviderConnectionPolicy::class);
|
||||
|
||||
expect($policy->update($user, $connection))->toBeTrue();
|
||||
|
||||
$result = $policy->manageDedicated($user, $connection);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns not found for non-members on dedicated override authorization checks', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$result = app(ProviderConnectionPolicy::class)->manageDedicated($user, $connection);
|
||||
|
||||
expect($result)->toBeInstanceOf(Response::class)
|
||||
->and($result->allowed())->toBeFalse()
|
||||
->and($result->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('returns not found when the provider connection is outside the active workspace', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
(int) $tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$result = app(ProviderConnectionPolicy::class)->manageDedicated($user, $connection);
|
||||
|
||||
expect($result)->toBeInstanceOf(Response::class)
|
||||
->and($result->allowed())->toBeFalse()
|
||||
->and($result->status())->toBe(404);
|
||||
});
|
||||
@ -20,7 +20,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySnapshot(string $tenantIde
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
|
||||
79
tests/Unit/Providers/AdminConsentUrlFactoryTest.php
Normal file
79
tests/Unit/Providers/AdminConsentUrlFactoryTest.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('builds admin consent urls for platform connections from the central platform identity', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
config()->set('graph.tenant_id', 'organizations');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'target-tenant-id',
|
||||
'app_client_id' => 'legacy-tenant-client-id',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'target-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'dedicated-fallback-client-id',
|
||||
'client_secret' => 'dedicated-fallback-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$url = app(AdminConsentUrlFactory::class)->make($connection, 'state-123');
|
||||
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
|
||||
expect($url)->toStartWith('https://login.microsoftonline.com/target-tenant-id/v2.0/adminconsent?')
|
||||
->and($query['client_id'] ?? null)->toBe('platform-client-id')
|
||||
->and($query['client_id'] ?? null)->not->toBe('legacy-tenant-client-id')
|
||||
->and($query['client_id'] ?? null)->not->toBe('dedicated-fallback-client-id')
|
||||
->and($query['redirect_uri'] ?? null)->toBe(route('admin.consent.callback'))
|
||||
->and($query['scope'] ?? null)->toBe('https://graph.microsoft.com/.default')
|
||||
->and($query['state'] ?? null)->toBe('state-123');
|
||||
});
|
||||
|
||||
it('builds admin consent urls for dedicated connections from the dedicated credential identity', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'dedicated-target-tenant-id',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'dedicated-target-tenant-id',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'dedicated-client-id',
|
||||
'client_secret' => 'dedicated-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$url = app(AdminConsentUrlFactory::class)->make($connection, 'state-456');
|
||||
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
|
||||
expect($url)->toStartWith('https://login.microsoftonline.com/dedicated-target-tenant-id/v2.0/adminconsent?')
|
||||
->and($query['client_id'] ?? null)->toBe('dedicated-client-id')
|
||||
->and($query['redirect_uri'] ?? null)->toBe(route('admin.consent.callback'))
|
||||
->and($query['state'] ?? null)->toBe('state-456');
|
||||
});
|
||||
@ -8,7 +8,7 @@
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns client credentials from encrypted payload', function (): void {
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
]);
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
});
|
||||
|
||||
it('rejects credential payload that does not match the connection scope', function (): void {
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'entra_tenant_id' => 'tenant-a',
|
||||
]);
|
||||
|
||||
@ -48,8 +48,26 @@
|
||||
$manager->getClientCredentials($connection);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('rejects resolving dedicated credentials for platform connections', function (): void {
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'entra_tenant_id' => 'tenant-a',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$manager = app(CredentialManager::class);
|
||||
|
||||
$manager->getClientCredentials($connection);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('upserts client secret credentials and never serializes the payload', function (): void {
|
||||
$connection = ProviderConnection::factory()->create();
|
||||
$connection = ProviderConnection::factory()->dedicated()->create();
|
||||
|
||||
$manager = app(CredentialManager::class);
|
||||
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Providers\PlatformProviderIdentityResolver;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
it('resolves the central platform identity from config', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
config()->set('graph.tenant_id', 'platform-home-tenant-id');
|
||||
|
||||
$resolution = app(PlatformProviderIdentityResolver::class)->resolve('customer-tenant-id');
|
||||
|
||||
expect($resolution->resolved)->toBeTrue()
|
||||
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||
->and($resolution->tenantContext)->toBe('customer-tenant-id')
|
||||
->and($resolution->effectiveClientId)->toBe('platform-client-id')
|
||||
->and($resolution->credentialSource)->toBe('platform_config')
|
||||
->and($resolution->clientSecret)->toBe('platform-client-secret')
|
||||
->and($resolution->authorityTenant)->toBe('platform-home-tenant-id')
|
||||
->and($resolution->redirectUri)->toBe(route('admin.consent.callback'));
|
||||
});
|
||||
|
||||
it('blocks platform identity resolution when the platform client id is missing', function (): void {
|
||||
config()->set('graph.client_id', '');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
$resolution = app(PlatformProviderIdentityResolver::class)->resolve('customer-tenant-id');
|
||||
|
||||
expect($resolution->resolved)->toBeFalse()
|
||||
->and($resolution->effectiveReasonCode())->toBe(ProviderReasonCodes::PlatformIdentityMissing);
|
||||
});
|
||||
|
||||
it('blocks platform identity resolution when the platform secret is missing', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', '');
|
||||
|
||||
$resolution = app(PlatformProviderIdentityResolver::class)->resolve('customer-tenant-id');
|
||||
|
||||
expect($resolution->resolved)->toBeFalse()
|
||||
->and($resolution->effectiveReasonCode())->toBe(ProviderReasonCodes::PlatformIdentityIncomplete);
|
||||
});
|
||||
27
tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php
Normal file
27
tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('normalizes provider connection status badges from consent and verification semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'required')->label)->toBe('Needs consent')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'revoked')->label)->toBe('Error')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'blocked')->label)->toBe('Error')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected')->label)->toBe('Connected');
|
||||
});
|
||||
|
||||
it('normalizes provider connection health badges from verification semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'healthy')->label)->toBe('OK')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'blocked')->label)->toBe('Down')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'error')->label)->toBe('Down')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown')->label)->toBe('Unknown');
|
||||
});
|
||||
|
||||
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'degraded')->label)->toBe('Needs attention')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'error')->label)->toBe('Blocked');
|
||||
});
|
||||
126
tests/Unit/Providers/ProviderConnectionClassifierTest.php
Normal file
126
tests/Unit/Providers/ProviderConnectionClassifierTest.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Providers\ProviderConnectionClassifier;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('classifies a platform-like connection without legacy identity as platform', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'platform-like-tenant-id',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'platform-like-tenant-id',
|
||||
]);
|
||||
|
||||
$result = app(ProviderConnectionClassifier::class)->classify($connection);
|
||||
|
||||
expect($result->suggestedConnectionType)->toBe(ProviderConnectionType::Platform)
|
||||
->and($result->reviewRequired)->toBeFalse()
|
||||
->and($result->metadata())->toMatchArray([
|
||||
'legacy_identity_classification_source' => 'migration_scan',
|
||||
'legacy_identity_review_required' => false,
|
||||
'legacy_identity_result' => 'platform',
|
||||
'effective_app' => [
|
||||
'app_id' => 'platform-client-id',
|
||||
'source' => 'platform_config',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies a credential-backed legacy connection as dedicated', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'dedicated-like-tenant-id',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'dedicated-like-tenant-id',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'source' => null,
|
||||
'payload' => [
|
||||
'client_id' => 'legacy-dedicated-client-id',
|
||||
'client_secret' => 'legacy-dedicated-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(ProviderConnectionClassifier::class)->classify($connection);
|
||||
|
||||
expect($result->suggestedConnectionType)->toBe(ProviderConnectionType::Dedicated)
|
||||
->and($result->reviewRequired)->toBeFalse()
|
||||
->and($result->metadata())->toMatchArray([
|
||||
'legacy_identity_result' => 'dedicated',
|
||||
'effective_app' => [
|
||||
'app_id' => 'legacy-dedicated-client-id',
|
||||
'source' => 'dedicated_credential',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags contradictory legacy identity as review required', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'hybrid-tenant-id',
|
||||
'app_client_id' => 'legacy-tenant-client-id',
|
||||
'app_client_secret' => 'legacy-tenant-secret',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'hybrid-tenant-id',
|
||||
'consent_status' => 'granted',
|
||||
'verification_status' => 'healthy',
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'source' => null,
|
||||
'payload' => [
|
||||
'client_id' => 'dedicated-client-id',
|
||||
'client_secret' => 'dedicated-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(ProviderConnectionClassifier::class)->classify($connection);
|
||||
|
||||
expect($result->suggestedConnectionType)->toBe(ProviderConnectionType::Dedicated)
|
||||
->and($result->reviewRequired)->toBeTrue()
|
||||
->and($result->metadata())->toMatchArray([
|
||||
'legacy_identity_review_required' => true,
|
||||
'legacy_identity_result' => 'dedicated',
|
||||
'effective_app' => [
|
||||
'app_id' => null,
|
||||
'source' => 'review_required',
|
||||
],
|
||||
])
|
||||
->and($result->signals)->toMatchArray([
|
||||
'tenant_client_id' => 'legacy-tenant-client-id',
|
||||
'credential_client_id' => 'dedicated-client-id',
|
||||
'consent_status' => 'granted',
|
||||
'verification_status' => 'healthy',
|
||||
'status' => 'connected',
|
||||
]);
|
||||
});
|
||||
@ -4,14 +4,13 @@
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('builds per-request graph context from a provider connection + credentials', function (): void {
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'entra_tenant_id' => 'entra-tenant-id',
|
||||
]);
|
||||
|
||||
@ -70,10 +69,9 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
}
|
||||
};
|
||||
|
||||
$gateway = new ProviderGateway(
|
||||
graph: $graph,
|
||||
credentials: app(CredentialManager::class),
|
||||
);
|
||||
$gateway = app()->make(ProviderGateway::class, [
|
||||
'graph' => $graph,
|
||||
]);
|
||||
|
||||
$gateway->getOrganization($connection);
|
||||
$gateway->getPolicy($connection, 'deviceConfiguration', 'policy-1', ['platform' => 'windows']);
|
||||
@ -118,3 +116,70 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($fifth['client_request_id'])->toBeString()->not->toBeEmpty();
|
||||
expect($fifth['query'])->toBe(['a' => 'b']);
|
||||
});
|
||||
|
||||
it('builds per-request graph context from the central platform identity for platform connections', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'entra_tenant_id' => 'entra-tenant-id',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'dedicated-fallback-client-id',
|
||||
'client_secret' => 'dedicated-fallback-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$graph = new class implements GraphClientInterface
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $lastOptions = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
$this->lastOptions = $options;
|
||||
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
};
|
||||
|
||||
$gateway = app()->make(ProviderGateway::class, [
|
||||
'graph' => $graph,
|
||||
]);
|
||||
|
||||
$gateway->getOrganization($connection);
|
||||
|
||||
expect($graph->lastOptions['tenant'] ?? null)->toBe('entra-tenant-id')
|
||||
->and($graph->lastOptions['client_id'] ?? null)->toBe('platform-client-id')
|
||||
->and($graph->lastOptions['client_secret'] ?? null)->toBe('platform-client-secret')
|
||||
->and($graph->lastOptions['client_id'] ?? null)->not->toBe('dedicated-fallback-client-id')
|
||||
->and($graph->lastOptions['client_request_id'] ?? null)->toBeString()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user