TenantAtlas/app/Services/Intune/RbacOnboardingService.php

758 lines
28 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Support\RbacReason;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class RbacOnboardingService
{
private const DEFAULT_GROUP_NAME = 'TenantPilot-Intune-RBAC';
public function __construct(
private readonly GraphClientInterface $graph,
private readonly AuditLogger $auditLogger,
) {}
/**
* Run the RBAC onboarding synchronously. Returns an array with status, ids, and warnings.
*
* @param array{role_definition_id:?string,role_display_name?:?string,scope?:string,scope_group_id?:?string,group_mode?:string,group_name?:?string,existing_group_id?:?string} $input
* @return array{status:string,message?:string,warnings:array<int,string>,service_principal_id:?string,group_id:?string,role_definition_id:?string,role_display_name:?string,role_assignment_id:?string,steps:array<int,string>}
*/
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<string, mixed>
*/
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<int, array<string, mixed>> $assignments
* @return array<int, array<string, mixed>>
*/
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<int, string>
*/
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<string,string>, warnings: array<int,string>}
*/
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,
);
}
}