TenantAtlas/apps/platform/app/Services/Providers/ProviderIdentityResolution.php
ahmido 023274c46c feat: normalize provider connection scope contracts (#339)
## Summary
- normalize provider-neutral target-scope and identity contracts across provider connection resolution, operation-start gating, verification reporting, and boundary configuration
- align provider connection resource, onboarding, tenant summaries, and operation follow-up on the same shared scope contract while keeping Microsoft-specific profile details in provider-owned metadata
- add Spec 281 artifacts and focused feature/browser coverage for the new provider-scope contract
- move the tenant dashboard context-chip rail into Filament header widgets so the metadata row renders directly under the page subtitle

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Filament remains on v5 with Livewire v4-compatible surfaces only.
- Provider registration location is unchanged; Laravel 11+ providers stay in `apps/platform/bootstrap/providers.php`.
- `ProviderConnectionResource` remains non-globally-searchable and still exposes View/Edit pages.
- No new asset registration was added; deploy-time `filament:assets` expectations are unchanged.
- No new destructive action path was introduced; existing server authorization and confirmation handling remain in place where applicable.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #339
2026-05-07 19:28:42 +00:00

136 lines
4.4 KiB
PHP

<?php
namespace App\Services\Providers;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
final class ProviderIdentityResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
private function __construct(
public readonly bool $resolved,
public readonly ProviderConnectionType $connectionType,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
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 readonly array $providerContextDetails,
) {}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function resolved(
ProviderConnectionType $connectionType,
ProviderConnectionTargetScopeDescriptor $targetScope,
string $effectiveClientId,
string $credentialSource,
?string $clientSecret,
?string $authorityTenant,
?string $redirectUri,
array $providerContextDetails = [],
): self {
return new self(
resolved: true,
connectionType: $connectionType,
targetScope: $targetScope,
effectiveClientId: $effectiveClientId,
credentialSource: $credentialSource,
clientSecret: $clientSecret,
authorityTenant: $authorityTenant,
redirectUri: $redirectUri,
reasonCode: null,
message: null,
providerContextDetails: $providerContextDetails,
);
}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function blocked(
ProviderConnectionType $connectionType,
string $credentialSource,
string $reasonCode,
?string $message = null,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $providerContextDetails = [],
): self {
return new self(
resolved: false,
connectionType: $connectionType,
targetScope: $targetScope,
effectiveClientId: null,
credentialSource: $credentialSource,
clientSecret: null,
authorityTenant: null,
redirectUri: null,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
message: $message,
providerContextDetails: $providerContextDetails,
);
}
public function effectiveReasonCode(): string
{
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
}
public function targetScopeIdentifier(?string $fallback = null): ?string
{
$identifier = trim((string) $this->targetScope?->scopeIdentifier);
if ($identifier !== '') {
return $identifier;
}
return $fallback;
}
/**
* @return array{client_id: ?string, credential_source: string}
*/
public function effectiveClientIdentity(): array
{
return [
'client_id' => $this->effectiveClientId,
'credential_source' => $this->credentialSource,
];
}
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(): array
{
$provider = $this->targetScope?->provider;
if (! is_string($provider) || trim($provider) === '') {
foreach ($this->providerContextDetails as $detail) {
if (trim($detail->provider) !== '') {
$provider = $detail->provider;
break;
}
}
}
return [
'provider' => is_string($provider) && trim($provider) !== '' ? trim($provider) : 'unknown',
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->providerContextDetails,
),
];
}
}