758 lines
28 KiB
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,
|
|
);
|
|
}
|
|
}
|