## Summary - standardize Microsoft provider connections around explicit platform vs dedicated identity modes - centralize admin-consent URL and runtime identity resolution so platform flows no longer fall back to tenant-local credentials - add migration classification, richer consent and verification state handling, dedicated override management, and focused regression coverage ## Validation - focused repo test coverage was added across provider identity, onboarding, audit, policy, guard, and migration flows - latest explicit passing run in the workspace: `vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Audit/ProviderConnectionConsentAuditTest.php` ## Notes - branch includes the full Spec 137 artifact set under `specs/137-platform-provider-identity/` - target base branch: `dev` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #166
183 lines
5.6 KiB
PHP
183 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Observers;
|
|
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Intune\AuditLogger;
|
|
|
|
class ProviderCredentialObserver
|
|
{
|
|
public function created(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_created',
|
|
credential: $credential,
|
|
changedFields: ['type', 'client_id', 'client_secret'],
|
|
);
|
|
}
|
|
|
|
public function updated(ProviderCredential $credential): void
|
|
{
|
|
$connection = $this->resolveConnection($credential);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return;
|
|
}
|
|
|
|
$tenant = $connection->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$changedFields = $this->changedFields($credential);
|
|
|
|
if ($changedFields === []) {
|
|
return;
|
|
}
|
|
|
|
$action = in_array('client_secret', $changedFields, true)
|
|
? 'provider_connection.credentials_rotated'
|
|
: 'provider_connection.credentials_updated';
|
|
|
|
$this->audit(
|
|
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');
|
|
|
|
return $credential->providerConnection;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function changedFields(ProviderCredential $credential): array
|
|
{
|
|
$fields = [];
|
|
|
|
if ($credential->isDirty('type') || $credential->wasChanged('type')) {
|
|
$fields[] = 'type';
|
|
}
|
|
|
|
$previousPayload = $credential->getOriginal('payload');
|
|
$currentPayload = $credential->payload;
|
|
|
|
$previousPayload = is_array($previousPayload) ? $previousPayload : [];
|
|
$currentPayload = is_array($currentPayload) ? $currentPayload : [];
|
|
|
|
$previousClientId = trim((string) ($previousPayload['client_id'] ?? ''));
|
|
$currentClientId = trim((string) ($currentPayload['client_id'] ?? ''));
|
|
|
|
if ($previousClientId !== $currentClientId) {
|
|
$fields[] = 'client_id';
|
|
}
|
|
|
|
$previousClientSecret = trim((string) ($previousPayload['client_secret'] ?? ''));
|
|
$currentClientSecret = trim((string) ($currentPayload['client_secret'] ?? ''));
|
|
|
|
if ($previousClientSecret !== $currentClientSecret) {
|
|
$fields[] = 'client_secret';
|
|
}
|
|
|
|
return array_values(array_unique($fields));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $changedFields
|
|
*/
|
|
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,
|
|
'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'],
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
status: 'success',
|
|
);
|
|
}
|
|
}
|