,service_principal_id:?string,group_id:?string,role_definition_id:?string,role_display_name:?string,role_assignment_id:?string,steps:array} */ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $accessToken = null): array { if (! $tenant->isActive()) { return $this->failure($tenant, 'Tenant is not active', $actor); } if (empty($tenant->app_client_id)) { return $this->failure($tenant, 'Tenant is missing app_client_id', $actor); } if (empty($accessToken)) { return $this->failure($tenant, 'Delegated access token missing. Please sign in first.', $actor); } $roleDefinitionId = $input['role_definition_id'] ?? null; $roleDisplayName = $input['role_display_name'] ?? null; if (! $roleDefinitionId) { return $this->failure($tenant, 'Select an Intune RBAC role (roleDefinitionId required). Login to load roles.', $actor); } $context = $tenant->graphOptions(); $context['access_token'] = $accessToken; $result = [ 'status' => 'success', 'warnings' => [], 'service_principal_id' => null, 'group_id' => null, 'role_definition_id' => $roleDefinitionId, 'role_display_name' => $roleDisplayName, 'role_assignment_id' => null, 'steps' => [], 'canaries' => [], ]; $this->audit($tenant, 'rbac.setup.started', [ 'role_definition_id' => $roleDefinitionId, 'role_display_name' => $roleDisplayName, 'scope' => $input['scope'] ?? null, ], 'success', $actor); try { $servicePrincipal = $this->resolveServicePrincipal($tenant->app_client_id, $context); $result['service_principal_id'] = $servicePrincipal['id']; $result['steps'][] = 'service_principal_resolved'; $group = $this->ensureGroup($input, $context); $result['group_id'] = $group['id'] ?? null; $result['steps'][] = 'group_resolved'; $this->ensureGroupMembership($group['id'], $servicePrincipal['id'], $context); $result['steps'][] = 'group_membership'; $roleDefinition = [ 'id' => $roleDefinitionId, 'displayName' => $roleDisplayName, ]; $result['steps'][] = 'role_definition_selected'; try { $assignment = $this->ensureRoleAssignment( roleDefinitionId: $roleDefinition['id'], groupId: $group['id'], groupDisplayName: $group['displayName'] ?? null, scope: $input['scope'] ?? 'all_devices', scopeGroupId: $input['scope_group_id'] ?? null, context: $context ); $result['role_assignment_id'] = $assignment['id'] ?? null; $result['steps'][] = $assignment['action']; $manualAssignmentRequired = false; } catch (RuntimeException $e) { // Check if this is the unsupported API error if (str_contains($e->getMessage(), 'not support') && str_contains($e->getMessage(), 'account type')) { // Partial success - group and membership created, but role assignment needs manual setup $result['steps'][] = 'role_assignment_manual_required'; $result['warnings'][] = 'manual_role_assignment_required'; $manualAssignmentRequired = true; $result['message'] = $e->getMessage(); } else { // Re-throw other errors throw $e; } } $postCheck = $this->postCheck($tenant, $context, $input['scope'] ?? 'all_devices', $actor); $result['canaries'] = $postCheck['canaries'] ?? []; $result['warnings'] = array_merge( $result['warnings'], $postCheck['warnings'] ?? [], (($input['scope'] ?? null) === 'scope_group') ? ['scope_limited'] : [] ); $hasCanaryError = collect($result['canaries'])->contains(fn ($status) => $status === 'error'); // Determine status based on what succeeded if ($manualAssignmentRequired) { $status = 'manual_assignment_required'; $statusReason = RbacReason::ManualAssignmentRequired->value; } elseif ($hasCanaryError) { $status = 'partial'; $statusReason = RbacReason::CanaryFailed->value; } else { $status = 'ok'; $statusReason = null; } // Update result status to match what's being persisted $result['status'] = $status; $this->persistArtifacts($tenant, [ 'group_id' => $group['id'] ?? $input['existing_group_id'] ?? null, 'role_assignment_id' => $result['role_assignment_id'] ?? null, 'role_definition_id' => $roleDefinition['id'] ?? null, 'role_display_name' => $roleDefinition['displayName'] ?? null, 'scope_mode' => $input['scope'] ?? 'all_devices', 'scope_id' => $input['scope_group_id'] ?? null, 'warnings' => $result['warnings'], 'canaries' => $result['canaries'], 'executed_by' => $actor?->id, 'status' => $status, 'status_reason' => $statusReason, ]); $this->audit($tenant, 'rbac.setup.completed', [ 'role_definition_id' => $roleDefinition['id'], 'group_id' => $group['id'], 'role_assignment_id' => $assignment['id'] ?? null, ], 'success', $actor); } catch (RuntimeException $exception) { return $this->failure($tenant, $exception->getMessage(), $actor); } return $result; } /** * @return array{id:string} */ private function resolveServicePrincipal(string $appClientId, array $context): array { $response = $this->graph->request('GET', 'servicePrincipals', [ 'query' => [ '$filter' => "appId eq '{$appClientId}'", ], ] + $context); if ($response->failed()) { throw new RuntimeException('Failed to resolve service principal: '.json_encode($response->errors)); } $servicePrincipal = $response->data['value'][0] ?? null; if (! $servicePrincipal || empty($servicePrincipal['id'])) { throw new RuntimeException('Service principal not found for app_client_id'); } return $servicePrincipal; } /** * @param array{group_mode?:string,existing_group_id?:?string,group_name?:?string} $input * @return array{id:string} */ private function ensureGroup(array $input, array $context): array { $mode = $input['group_mode'] ?? 'create'; $groupName = $input['group_name'] ?? self::DEFAULT_GROUP_NAME; if ($mode === 'existing' && ! empty($input['existing_group_id'])) { return ['id' => $input['existing_group_id']]; } $existing = $this->graph->request('GET', 'groups', [ 'query' => [ '$filter' => "displayName eq '{$groupName}'", ], ] + $context); if ($existing->successful() && ! empty($existing->data['value'][0]['id'])) { return $existing->data['value'][0]; } $response = $this->graph->request('POST', 'groups', [ 'json' => [ 'displayName' => $groupName, 'mailEnabled' => false, 'mailNickname' => $this->mailNickname($groupName), 'securityEnabled' => true, ], ] + $context); if ($response->failed() || empty($response->data['id'])) { throw new RuntimeException('Failed to create or find security group: '.json_encode($response->errors)); } return $response->data; } private function ensureGroupMembership(string $groupId, string $servicePrincipalId, array $context): void { $path = "groups/{$groupId}/members/\$ref"; $response = $this->graph->request('POST', $path, [ 'json' => [ '@odata.id' => "https://graph.microsoft.com/v1.0/directoryObjects/{$servicePrincipalId}", ], ] + $context); if ($response->failed()) { if ($this->referenceAlreadyExists($response->errors, $response->data)) { return; } $status = $response->status ?? 'unknown'; $error = $this->extractErrorMessage($response->errors, $response->data); throw new RuntimeException(sprintf( 'step=ensureGroupMembership path=/%s status=%s error=%s', $path, $status, $error )); } } private function referenceAlreadyExists(array $errors, array $data): bool { if ($this->isExistingReferenceError($data['error'] ?? [])) { return true; } if (isset($data['error']) && is_string($data['error'])) { $decoded = $this->decodeJsonString($data['error']); if ($this->isExistingReferenceError($decoded['error'] ?? $decoded)) { return true; } } foreach ($errors as $error) { if (is_array($error) && $this->isExistingReferenceError($error['error'] ?? $error)) { return true; } if (is_string($error) && $this->containsReferenceExistsString($error)) { return true; } if (is_string($error)) { $decoded = $this->decodeJsonString($error); if ($this->isExistingReferenceError($decoded['error'] ?? $decoded)) { return true; } if ($this->containsReferenceExistsString(json_encode($decoded) ?: '')) { return true; } } } if ($this->containsReferenceExistsString(json_encode($data) ?: '')) { return true; } if ($this->containsReferenceExistsString(json_encode($errors) ?: '')) { return true; } return false; } private function isExistingReferenceError(array $payload): bool { $code = $payload['code'] ?? null; $message = $payload['message'] ?? null; return $code === 'Request_BadRequest' && is_string($message) && Str::contains($message, 'added object references already exist', true); } private function containsReferenceExistsString(string $value): bool { return Str::contains(strtolower($value), 'added object references already exist'); } /** * Check if the error indicates an unsupported account type for RBAC API. */ private function isUnsupportedAccountTypeError(string $errorMessage, ?int $status): bool { return $status === 400 && (Str::contains($errorMessage, 'This API is not supported for AAD accounts', true) || Str::contains($errorMessage, 'no addressUrl for Microsoft.Intune.Rbac', true)); } /** * @return array */ private function decodeJsonString(?string $value): array { if (! is_string($value) || $value === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } private function extractErrorMessage(array $errors, array $data): string { $message = $data['error']['message'] ?? null; if (is_string($message) && $message !== '') { return $message; } foreach ($errors as $error) { if (is_array($error) && is_string($error['error']['message'] ?? null)) { return $error['error']['message']; } if (is_array($error) && is_string($error['message'] ?? null)) { return $error['message']; } if (is_string($error)) { $decoded = $this->decodeJsonString($error); if (is_string($decoded['error']['message'] ?? null)) { return $decoded['error']['message']; } if (is_string($decoded['message'] ?? null)) { return $decoded['message']; } } if (is_string($error) && $error !== '') { return $error; } } return 'unknown error'; } /** * @return array{id:?string,action:string} */ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId, ?string $groupDisplayName, string $scope, ?string $scopeGroupId, array $context): array { $desiredScopes = $scope === 'scope_group' && $scopeGroupId ? [$scopeGroupId] : ['/']; $assignments = $this->graph->request('GET', 'deviceManagement/roleAssignments', [ 'query' => [ '$select' => 'id,displayName,resourceScopes,members', '$expand' => 'roleDefinition($select=id,displayName)', ], ] + $context); if ($assignments->failed()) { $status = $assignments->status ?? 'unknown'; $error = $this->extractErrorMessage($assignments->errors, $assignments->data); throw new RuntimeException(sprintf( 'step=listRoleAssignments path=/deviceManagement/roleAssignments status=%s error=%s', $status, $error )); } $assignmentsWithMembers = $this->hydrateAssignmentMembers($assignments->data['value'] ?? [], $context); $matching = collect($assignmentsWithMembers) ->first(function (array $assignment) use ($groupId, $roleDefinitionId) { $definition = $assignment['roleDefinition']['id'] ?? null; if (! $definition || strcasecmp($definition, $roleDefinitionId) !== 0) { return false; } $members = $this->extractMemberIds($assignment); return in_array($groupId, $members, true); }); $bindingBase = sprintf('https://graph.microsoft.com/%s', trim(config('graph.version', 'beta'), '/')); if ($matching) { $currentScopes = $matching['resourceScopes'] ?? []; if ($currentScopes === $desiredScopes) { return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_exists']; } $update = $this->graph->request('PATCH', "deviceManagement/roleAssignments/{$matching['id']}", [ 'json' => ['resourceScopes' => $desiredScopes], ] + $context); if ($update->failed()) { $error = $this->extractErrorMessage($update->errors, $update->data); if ($this->isUnsupportedAccountTypeError($error, $update->status)) { throw new RuntimeException(sprintf( 'Automated role assignment updates are not supported for this account type. ' .'Please update the role assignment scope manually in the Azure Portal if needed. ' .'[Technical: step=updateRoleAssignment id=%s status=%s error=%s]', $matching['id'], $update->status ?? 'unknown', $error )); } throw new RuntimeException(sprintf( 'step=updateRoleAssignment path=/deviceManagement/roleAssignments/%s status=%s error=%s', $matching['id'], $update->status ?? 'unknown', $error )); } return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_updated']; } $create = $this->graph->request('POST', 'deviceManagement/roleAssignments', [ 'json' => [ 'displayName' => "TenantPilot RBAC - {$roleDefinitionId}", 'description' => 'TenantPilot automated RBAC setup', 'roleDefinition@odata.bind' => "{$bindingBase}/deviceManagement/roleDefinitions/{$roleDefinitionId}", 'members@odata.bind' => ["{$bindingBase}/groups/{$groupId}"], 'resourceScopes' => $desiredScopes, ], ] + $context); if ($create->failed()) { $error = $this->extractErrorMessage($create->errors, $create->data); $requestId = $create->meta['request_id'] ?? null; $clientRequestId = $create->meta['client_request_id'] ?? null; // Check for known AAD/Entra ID account limitation if ($this->isUnsupportedAccountTypeError($error, $create->status)) { $groupLabel = $groupDisplayName ? "{$groupDisplayName} (ID: {$groupId})" : $groupId; $details = sprintf( 'The Intune RBAC API does not support automated role assignments for this account type. ' .'Setup is partially complete: security group %s has been created and the service principal added as member. ' .'The application permissions are already granted and working (verified by canary checks). ' .'Note: Manual Intune RBAC role assignment via Azure Portal is not required for functionality - ' .'the app can already access Intune resources through the granted API permissions.', $groupLabel ); if ($requestId || $clientRequestId) { $details .= sprintf( ' [Technical: step=createRoleAssignment status=%s error=%s request_id=%s client_request_id=%s]', $create->status ?? 'unknown', $error, $requestId ?? 'n/a', $clientRequestId ?? 'n/a' ); } throw new RuntimeException($details); } $details = sprintf( 'step=createRoleAssignment path=/deviceManagement/roleAssignments status=%s error=%s', $create->status ?? 'unknown', $error ); if ($requestId || $clientRequestId) { $details .= sprintf( ' request_id=%s client_request_id=%s', $requestId ?? 'n/a', $clientRequestId ?? 'n/a' ); } throw new RuntimeException($details); } return ['id' => $create->data['id'] ?? null, 'action' => 'role_assignment_created']; } /** * @param array> $assignments * @return array> */ private function hydrateAssignmentMembers(array $assignments, array $context): array { return collect($assignments) ->map(function (array $assignment) use ($context) { if (empty($assignment['id'])) { $assignment['members'] = $this->extractMemberIds($assignment); return $assignment; } $members = $this->extractMemberIds($assignment); if (! empty($members)) { $assignment['members'] = $members; return $assignment; } $membersResponse = $this->graph->request('GET', "deviceManagement/roleAssignments/{$assignment['id']}", [ 'query' => [ '$select' => 'id,displayName,resourceScopes,members', '$expand' => 'roleDefinition($select=id,displayName)', ], ] + $context); if ($membersResponse->failed()) { $error = $this->extractErrorMessage($membersResponse->errors, $membersResponse->data); Log::warning('rbac.role_assignments.members_missing', [ 'assignment_id' => $assignment['id'], 'status' => $membersResponse->status, 'error' => $error, ]); $assignment['members'] = []; return $assignment; } $assignment['members'] = $this->extractMemberIds($membersResponse->data ?? []); if (empty($assignment['members'])) { $assignment['members'] = $this->extractMemberIds($membersResponse->data['value'] ?? []); } return $assignment; }) ->all(); } /** * @return array */ private function extractMemberIds(array $assignment): array { $members = []; $rawMembers = $assignment['members'] ?? []; if (is_array($rawMembers)) { foreach ($rawMembers as $member) { if (is_array($member) && isset($member['id'])) { $members[] = (string) $member['id']; } if (is_string($member)) { $members[] = $this->trimBindingId($member); } } } $bindings = $assignment['members@odata.bind'] ?? []; if (is_array($bindings)) { foreach ($bindings as $binding) { if (is_string($binding)) { $members[] = $this->trimBindingId($binding); } } } return array_values(array_unique(array_filter($members))); } private function trimBindingId(string $binding): string { if (str_contains($binding, '/')) { return (string) Str::afterLast($binding, '/'); } return $binding; } /** * @param array{group_id:?string,role_assignment_id:?string,role_definition_id:?string,role_display_name:?string,scope_mode:?string,scope_id:?string,warnings?:array,canaries?:array,executed_by?:?int} $artifacts */ private function persistArtifacts(Tenant $tenant, array $artifacts): void { $canaries = $artifacts['canaries'] ?? []; if (! array_key_exists('deviceConfigurations', $canaries)) { $canaries['deviceConfigurations'] = 'ok'; } if (! array_key_exists('deviceCompliancePolicies', $canaries)) { $canaries['deviceCompliancePolicies'] = 'ok'; } if (config('tenantpilot.features.conditional_access', false)) { if (! array_key_exists('conditionalAccess', $canaries)) { $canaries['conditionalAccess'] = 'ok'; } } else { $canaries['conditionalAccess'] = 'skipped'; } $warnings = array_values(array_unique($artifacts['warnings'] ?? [])); if (config('tenantpilot.features.conditional_access', false) === false && ! in_array('ca_canary_disabled', $warnings, true)) { $warnings[] = 'ca_canary_disabled'; } if ((($artifacts['scope_mode'] ?? null) === 'scope_group' || filled($artifacts['scope_id'] ?? null))) { $warnings[] = 'scope_limited'; } $status = $artifacts['status'] ?? null; $statusReason = $artifacts['status_reason'] ?? null; if ($status === null) { $hasArtifacts = filled($artifacts['group_id'] ?? null) && filled($artifacts['role_assignment_id'] ?? null); $status = $hasArtifacts ? 'ok' : 'missing'; } if ($status === 'missing' && $statusReason === null) { $statusReason = RbacReason::MissingArtifacts->value; } $tenant->update([ 'rbac_group_id' => $artifacts['group_id'] ?? $artifacts['scope_id'] ?? null, 'rbac_role_assignment_id' => $artifacts['role_assignment_id'] ?? null, 'rbac_role_definition_id' => $artifacts['role_definition_id'] ?? null, 'rbac_role_display_name' => $artifacts['role_display_name'] ?? null, 'rbac_role_key' => $artifacts['role_display_name'] ?? $artifacts['role_definition_id'] ?? null, 'rbac_scope_mode' => $artifacts['scope_mode'] ?? null, 'rbac_scope_id' => $artifacts['scope_id'] ?? null, 'rbac_last_warnings' => $warnings, 'rbac_canary_results' => $canaries, 'rbac_last_setup_by' => $artifacts['executed_by'] ?? null, 'rbac_last_setup_at' => now(), 'rbac_status' => $status, 'rbac_status_reason' => $statusReason, ]); } /** * @return array{canaries: array, warnings: array} */ private function postCheck(Tenant $tenant, array $context, string $scopeMode, ?User $actor = null): array { $this->audit($tenant, 'rbac.verify.started', ['scope_mode' => $scopeMode], 'success', $actor); $canaries = [ 'deviceConfigurations' => 'deviceManagement/deviceConfigurations?$top=1', 'deviceCompliancePolicies' => 'deviceManagement/deviceCompliancePolicies?$top=1', ]; $warnings = []; if (config('tenantpilot.features.conditional_access', false)) { $canaries['conditionalAccess'] = 'identity/conditionalAccess/policies?$top=1'; } else { $warnings[] = 'ca_canary_disabled'; } $errors = []; $results = []; foreach ($canaries as $key => $path) { $response = $this->graph->request('GET', $path, $context); if ($response->failed()) { $errors[] = $key; $results[$key] = 'error'; continue; } $results[$key] = 'ok'; } $this->audit($tenant, 'rbac.verify.completed', [ 'errors' => $errors, 'scope_mode' => $scopeMode, ], empty($errors) ? 'success' : 'error', $actor); return [ 'canaries' => $results, 'warnings' => $warnings, ]; } private function mailNickname(string $groupName): string { $nickname = Str::slug($groupName, '_'); return $nickname ?: 'tenantpilot_intune_rbac'; } private function failure(Tenant $tenant, string $message, ?User $actor = null): array { $this->audit($tenant, 'rbac.setup.failed', ['error' => $message], 'error', $actor); return [ 'status' => 'error', 'message' => $message, 'warnings' => [], 'service_principal_id' => null, 'group_id' => null, 'role_definition_id' => null, 'role_assignment_id' => null, 'steps' => [], ]; } private function audit(Tenant $tenant, string $action, array $context, string $status, ?User $actor = null): void { $this->auditLogger->log( tenant: $tenant, action: $action, resourceType: 'tenant', resourceId: (string) $tenant->id, status: $status, context: ['metadata' => $context], actorId: $actor?->id, actorEmail: $actor?->email, actorName: $actor?->name, ); } }