TenantAtlas/app/Services/Intune/RbacHealthService.php
ahmido 4db8030f2a Spec 081: Provider connection cutover (#98)
Implements Spec 081 provider-connection cutover.

Highlights:
- Adds provider connection resolution + gating for operations/verification.
- Adds provider credential observer wiring.
- Updates Filament tenant verify flow to block with next-steps when provider connection isn’t ready.
- Adds spec docs under specs/081-provider-connection-cutover/ and extensive Spec081 test coverage.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantSetupTest.php
- Focused suites for ProviderConnections/Verification ran during implementation (see local logs).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #98
2026-02-08 11:28:51 +00:00

215 lines
8.1 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
use Carbon\CarbonImmutable;
use RuntimeException;
class RbacHealthService
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
/**
* @return array{status:string,reason:?string,used_artifacts:bool}
*/
public function check(Tenant $tenant): array
{
if (! $tenant->isActive()) {
return $this->record($tenant, 'error', RbacReason::MissingArtifacts->value, false);
}
$artifactsPresent = filled($tenant->rbac_group_id) || filled($tenant->rbac_role_assignment_id);
if (! $artifactsPresent) {
return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
}
try {
$connection = $this->resolveProviderConnection($tenant);
} catch (RuntimeException) {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
$context = $this->providerGateway()->graphOptions($connection);
$appClientId = is_string($context['client_id'] ?? null) ? (string) $context['client_id'] : null;
if ($appClientId === null || $appClientId === '') {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
$spId = $this->resolveServicePrincipalId($appClientId, $context);
if (! $spId) {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
if ($tenant->rbac_role_assignment_id) {
$response = $this->graph->request('GET', "deviceManagement/roleAssignments/{$tenant->rbac_role_assignment_id}", $context);
if ($response->successful()) {
$assignment = $response->data;
if (! $this->matchesScope($assignment, $tenant)) {
return $this->record($tenant, 'partial', RbacReason::ScopeMismatch->value, true);
}
if (! $this->matchesRole($assignment, $tenant, $context)) {
return $this->record($tenant, 'partial', RbacReason::RoleMismatch->value, true);
}
if (! $this->assignmentIncludesGroup($assignment, $tenant)) {
return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
}
return $this->record($tenant, 'ok', null, true);
}
}
if ($tenant->rbac_group_id) {
$response = $this->graph->request('GET', "groups/{$tenant->rbac_group_id}", $context);
if ($response->failed()) {
return $this->record($tenant, 'error', RbacReason::GroupMissing->value, true);
}
if (! $this->groupHasServicePrincipal($tenant->rbac_group_id, $spId, $context)) {
return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
}
// If group exists and SP is a member, but no role assignment found via API,
// check if this tenant requires manual assignment (unsupported API)
$hasManualAssignmentWarning = is_array($tenant->rbac_last_warnings)
&& in_array('manual_role_assignment_required', $tenant->rbac_last_warnings, true);
if ($tenant->rbac_status_reason === RbacReason::ManualAssignmentRequired->value || $hasManualAssignmentWarning) {
// Keep the manual_assignment_required status - group and membership are OK
// This account type doesn't support the Intune RBAC API, but permissions work via app registration
return $this->record($tenant, 'manual_assignment_required', RbacReason::ManualAssignmentRequired->value, true);
}
}
return $this->record($tenant, 'missing', RbacReason::AssignmentMissing->value, true);
}
/**
* @return array{status:string,reason:?string,used_artifacts:bool}
*/
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
{
$tenant->update([
'rbac_status' => $status,
'rbac_status_reason' => $reason,
'rbac_last_checked_at' => CarbonImmutable::now(),
]);
return [
'status' => $status,
'reason' => $reason,
'used_artifacts' => $usedArtifacts,
];
}
private function resolveServicePrincipalId(string $appClientId, array $context): ?string
{
$response = $this->graph->request('GET', 'servicePrincipals', [
'query' => [
'$filter' => "appId eq '{$appClientId}'",
],
] + $context);
return $response->successful()
? ($response->data['value'][0]['id'] ?? null)
: null;
}
private function groupHasServicePrincipal(string $groupId, string $spId, array $context): bool
{
$response = $this->graph->request('GET', "groups/{$groupId}/members", $context);
if (! $response->successful()) {
return false;
}
$members = $response->data['value'] ?? [];
return collect($members)->contains(fn ($member) => ($member['id'] ?? null) === $spId);
}
private function matchesScope(array $assignment, Tenant $tenant): bool
{
$scopes = $assignment['resourceScopes'] ?? [];
$expected = ($tenant->rbac_scope_mode === 'scope_group' && filled($tenant->rbac_scope_id))
? [$tenant->rbac_scope_id]
: ['/'];
return $scopes === $expected;
}
private function matchesRole(array $assignment, Tenant $tenant, array $context): bool
{
$roleDefinitionId = data_get($assignment, 'roleDefinition.id') ?? data_get($assignment, 'roleDefinitionId');
$expectedId = $tenant->rbac_role_definition_id;
if (! $expectedId || ! $roleDefinitionId) {
return true;
}
return strcasecmp($expectedId, $roleDefinitionId) === 0;
}
private function assignmentIncludesGroup(array $assignment, Tenant $tenant): bool
{
$bindingBase = sprintf('https://graph.microsoft.com/%s', trim(config('graph.version', 'beta'), '/'));
$expected = "{$bindingBase}/groups/{$tenant->rbac_group_id}";
$members = $assignment['members@odata.bind'] ?? $assignment['members'] ?? [];
if (empty($members) && isset($assignment['members@odata.bind'])) {
$members = $assignment['members@odata.bind'];
}
if (isset($assignment['members']) && is_array($assignment['members'])) {
return collect($assignment['members'])->contains(fn ($id) => $id === $tenant->rbac_group_id);
}
return collect($members)->contains($expected);
}
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection;
}
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
));
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
}