Spec 076: Tenant Required Permissions (enterprise remediation UX) #92
184
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
184
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'required-permissions';
|
||||
|
||||
protected static ?string $title = 'Required permissions';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||
|
||||
public string $status = 'missing';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $features = [];
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$queryFeatures = request()->query('features', $this->features);
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => request()->query('status', $this->status),
|
||||
'type' => request()->query('type', $this->type),
|
||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||
'search' => request()->query('search', $this->search),
|
||||
]);
|
||||
|
||||
$this->status = $state['status'];
|
||||
$this->type = $state['type'];
|
||||
$this->features = $state['features'];
|
||||
$this->search = $state['search'];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedStatus(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedType(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedFeatures(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function applyFeatureFilter(string $feature): void
|
||||
{
|
||||
$feature = trim($feature);
|
||||
|
||||
if ($feature === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($feature, $this->features, true)) {
|
||||
$this->features = array_values(array_filter(
|
||||
$this->features,
|
||||
static fn (string $value): bool => $value !== $feature,
|
||||
));
|
||||
} else {
|
||||
$this->features[] = $feature;
|
||||
}
|
||||
|
||||
$this->features = array_values(array_unique($this->features));
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function clearFeatureFilter(): void
|
||||
{
|
||||
$this->features = [];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->status = 'missing';
|
||||
$this->type = 'all';
|
||||
$this->features = [];
|
||||
$this->search = '';
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||
|
||||
$this->viewModel = $builder->build($tenant, [
|
||||
'status' => $this->status,
|
||||
'type' => $this->type,
|
||||
'features' => $this->features,
|
||||
'search' => $this->search,
|
||||
]);
|
||||
|
||||
$filters = $this->viewModel['filters'] ?? null;
|
||||
|
||||
if (is_array($filters)) {
|
||||
$this->status = (string) ($filters['status'] ?? $this->status);
|
||||
$this->type = (string) ($filters['type'] ?? $this->type);
|
||||
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
||||
$this->search = (string) ($filters['search'] ?? $this->search);
|
||||
}
|
||||
}
|
||||
|
||||
public function reRunVerificationUrl(): ?string
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$connectionId = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
|
||||
if (! is_int($connectionId)) {
|
||||
return ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -17,6 +18,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
@ -48,6 +50,7 @@
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -214,7 +217,57 @@ public function content(Schema $schema): Schema
|
||||
->label('Provider connection')
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'existing')
|
||||
->options(fn (): array => $this->providerConnectionOptions())
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'),
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing')
|
||||
->hintActions([
|
||||
Action::make('edit_selected_connection')
|
||||
->label('Edit selected connection')
|
||||
->icon('heroicon-m-pencil-square')
|
||||
->color('gray')
|
||||
->slideOver()
|
||||
->modalHeading('Edit provider connection')
|
||||
->modalDescription('Changes apply to this workspace connection.')
|
||||
->modalSubmitActionLabel('Save changes')
|
||||
->closeModalByClickingAway(false)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'
|
||||
&& is_numeric($get('provider_connection_id')))
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE)
|
||||
? null
|
||||
: 'You don\'t have permission to edit connections.')
|
||||
->fillForm(function (Get $get): array {
|
||||
$recordId = $get('provider_connection_id');
|
||||
|
||||
if (! is_numeric($recordId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->inlineEditSelectedConnectionFill((int) $recordId);
|
||||
})
|
||||
->form([
|
||||
TextInput::make('display_name')
|
||||
->label('Connection name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Directory (tenant) ID')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
TextInput::make('client_id')
|
||||
->label('App (client) ID')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('The client secret is not editable here.'),
|
||||
])
|
||||
->action(function (array $data, Get $get): void {
|
||||
$recordId = $get('provider_connection_id');
|
||||
|
||||
if (! is_numeric($recordId)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->updateSelectedProviderConnectionInline((int) $recordId, $data);
|
||||
}),
|
||||
]),
|
||||
|
||||
TextInput::make('new_connection.display_name')
|
||||
->label('Display name')
|
||||
@ -281,20 +334,50 @@ public function content(Schema $schema): Schema
|
||||
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
|
||||
->badge()
|
||||
->color(fn (): string => $this->verificationStatusColor()),
|
||||
Text::make('Verification is in progress. Use “Refresh” to see the latest stored status.')
|
||||
Text::make('Connection updated — re-run verification to refresh results.')
|
||||
->visible(fn (): bool => $this->connectionRecentlyUpdated()),
|
||||
Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
|
||||
->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()),
|
||||
Text::make('Verification is in progress. Use “Refresh results” to see the latest stored status.')
|
||||
->visible(fn (): bool => $this->verificationStatus() === 'in_progress'),
|
||||
SchemaActions::make([
|
||||
Action::make('wizardStartVerification')
|
||||
->label('Start verification')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationRunUrl() === null)
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||
? null
|
||||
: 'You do not have permission to start verification.')
|
||||
->action(fn () => $this->startVerification()),
|
||||
Action::make('wizardStartNewVerification')
|
||||
->label('Start new verification')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant
|
||||
&& $this->verificationRunUrl() !== null
|
||||
&& ! $this->verificationRunIsActive())
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||
? null
|
||||
: 'You do not have permission to start verification.')
|
||||
->action(fn () => $this->startVerification()),
|
||||
Action::make('wizardRefreshVerification')
|
||||
->label('Refresh')
|
||||
->label('Refresh results')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationRunIsActive())
|
||||
->action(fn () => $this->refreshVerificationStatus()),
|
||||
Action::make('wizardVerificationTechnicalDetails')
|
||||
->label('Technical details')
|
||||
->link()
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null)
|
||||
->slideOver()
|
||||
->modalHeading('Technical details')
|
||||
->modalDescription('Compact summary for the latest verification run.')
|
||||
->modalSubmitActionLabel('Refresh results')
|
||||
->modalCancelActionLabel(fn (): string => $this->verificationRunIsActive() ? 'Close' : 'Back to report')
|
||||
->modalSubmitAction(fn ($action) => $action->visible($this->verificationRunIsActive()))
|
||||
->modalContent(fn (): View => view(
|
||||
'filament.modals.onboarding-verification-technical-details',
|
||||
$this->verificationTechnicalDetailsViewData(),
|
||||
))
|
||||
->action(fn () => $this->refreshVerificationStatus()),
|
||||
]),
|
||||
ViewField::make('verification_report')
|
||||
@ -560,6 +643,10 @@ private function verificationStatus(): string
|
||||
return 'not_started';
|
||||
}
|
||||
|
||||
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
||||
return 'needs_attention';
|
||||
}
|
||||
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return 'in_progress';
|
||||
}
|
||||
@ -601,6 +688,14 @@ private function verificationStatus(): string
|
||||
return 'needs_attention';
|
||||
}
|
||||
|
||||
private function verificationRunIsActive(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
return $run instanceof OperationRun
|
||||
&& $run->status !== OperationRunStatus::Completed->value;
|
||||
}
|
||||
|
||||
private function verificationStatusColor(): string
|
||||
{
|
||||
return BadgeCatalog::spec(
|
||||
@ -640,6 +735,8 @@ private function verificationReportViewData(): array
|
||||
return [
|
||||
'run' => null,
|
||||
'runUrl' => $runUrl,
|
||||
'verification_report' => null,
|
||||
'is_stale_for_selected_connection' => false,
|
||||
];
|
||||
}
|
||||
|
||||
@ -648,6 +745,7 @@ private function verificationReportViewData(): array
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
@ -662,6 +760,45 @@ private function verificationReportViewData(): array
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
'verification_report' => $verificationReport,
|
||||
'is_stale_for_selected_connection' => ! $this->verificationRunMatchesSelectedConnection($run),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{run: array<string, mixed>|null, runUrl: string|null}
|
||||
*/
|
||||
private function verificationTechnicalDetailsViewData(): array
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
$runUrl = $this->verificationRunUrl();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [
|
||||
'run' => null,
|
||||
'runUrl' => $runUrl,
|
||||
];
|
||||
}
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'started_at' => $run->started_at?->toJSON(),
|
||||
'updated_at' => $run->updated_at?->toJSON(),
|
||||
'completed_at' => $run->completed_at?->toJSON(),
|
||||
'target_scope' => $targetScope,
|
||||
],
|
||||
'hasReport' => is_array($verificationReport),
|
||||
'runUrl' => $runUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -938,12 +1075,18 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$previousProviderConnectionId = $this->selectedProviderConnectionId;
|
||||
|
||||
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
|
||||
state: array_merge($this->onboardingSession->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]),
|
||||
previousProviderConnectionId: $previousProviderConnectionId,
|
||||
newProviderConnectionId: (int) $connection->getKey(),
|
||||
);
|
||||
$this->onboardingSession->current_step = 'connection';
|
||||
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
||||
$this->onboardingSession->save();
|
||||
@ -1021,9 +1164,18 @@ public function createProviderConnection(array $data): void
|
||||
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
$previousProviderConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
||||
$previousProviderConnectionId = is_int($previousProviderConnectionId)
|
||||
? $previousProviderConnectionId
|
||||
: (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null);
|
||||
|
||||
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
|
||||
state: array_merge($this->onboardingSession->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]),
|
||||
previousProviderConnectionId: $previousProviderConnectionId,
|
||||
newProviderConnectionId: (int) $connection->getKey(),
|
||||
);
|
||||
$this->onboardingSession->current_step = 'connection';
|
||||
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
||||
$this->onboardingSession->save();
|
||||
@ -1097,6 +1249,7 @@ public function startVerification(): void
|
||||
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $result->run->getKey(),
|
||||
'connection_recently_updated' => false,
|
||||
]);
|
||||
$this->onboardingSession->current_step = 'verify';
|
||||
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
||||
@ -1576,7 +1729,237 @@ private function verificationHasSucceeded(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $run->status === OperationRunStatus::Completed->value && $run->outcome === OperationRunOutcome::Succeeded->value;
|
||||
return $run->status === OperationRunStatus::Completed->value
|
||||
&& $run->outcome === OperationRunOutcome::Succeeded->value
|
||||
&& $this->verificationRunMatchesSelectedConnection($run);
|
||||
}
|
||||
|
||||
private function verificationRunIsStaleForSelectedConnection(): bool
|
||||
{
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->verificationRunMatchesSelectedConnection($run);
|
||||
}
|
||||
|
||||
private function verificationRunMatchesSelectedConnection(OperationRun $run): bool
|
||||
{
|
||||
$selectedProviderConnectionId = $this->selectedProviderConnectionId;
|
||||
|
||||
if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
||||
$selectedProviderConnectionId = is_int($candidate)
|
||||
? $candidate
|
||||
: (is_numeric($candidate) ? (int) $candidate : null);
|
||||
}
|
||||
|
||||
if ($selectedProviderConnectionId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$runProviderConnectionId = $context['provider_connection_id'] ?? null;
|
||||
$runProviderConnectionId = is_int($runProviderConnectionId)
|
||||
? $runProviderConnectionId
|
||||
: (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null);
|
||||
|
||||
if ($runProviderConnectionId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $runProviderConnectionId === $selectedProviderConnectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resetDependentOnboardingStateOnConnectionChange(array $state, ?int $previousProviderConnectionId, int $newProviderConnectionId): array
|
||||
{
|
||||
if ($previousProviderConnectionId === null) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
if ($previousProviderConnectionId === $newProviderConnectionId) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
unset(
|
||||
$state['verification_operation_run_id'],
|
||||
$state['bootstrap_operation_runs'],
|
||||
$state['bootstrap_operation_types'],
|
||||
);
|
||||
|
||||
$state['connection_recently_updated'] = true;
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function connectionRecentlyUpdated(): bool
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($this->onboardingSession->state['connection_recently_updated'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{display_name: string, entra_tenant_id: string, client_id: string}
|
||||
*/
|
||||
private function inlineEditSelectedConnectionFill(int $providerConnectionId): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->with('credential')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $this->managedTenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$payload = $connection->credential?->payload;
|
||||
$payload = is_array($payload) ? $payload : [];
|
||||
|
||||
return [
|
||||
'display_name' => (string) $connection->display_name,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'client_id' => (string) ($payload['client_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{display_name?: mixed, client_id?: mixed} $data
|
||||
*/
|
||||
public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->with('credential')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', (int) $this->managedTenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$displayName = trim((string) ($data['display_name'] ?? ''));
|
||||
$clientId = trim((string) ($data['client_id'] ?? ''));
|
||||
|
||||
if ($displayName === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'display_name' => 'Connection name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($clientId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'client_id' => 'App (client) ID is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
$existingPayload = $connection->credential?->payload;
|
||||
$existingPayload = is_array($existingPayload) ? $existingPayload : [];
|
||||
$existingClientId = trim((string) ($existingPayload['client_id'] ?? ''));
|
||||
|
||||
$changedFields = [];
|
||||
|
||||
if ($displayName !== (string) $connection->display_name) {
|
||||
$changedFields[] = 'display_name';
|
||||
}
|
||||
|
||||
if ($clientId !== $existingClientId) {
|
||||
$changedFields[] = 'client_id';
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($connection, $displayName, $clientId): void {
|
||||
$connection->forceFill([
|
||||
'display_name' => $displayName,
|
||||
])->save();
|
||||
|
||||
app(CredentialManager::class)->updateClientIdPreservingSecret(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
);
|
||||
});
|
||||
|
||||
if ($changedFields !== []) {
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $this->managedTenant,
|
||||
action: 'provider_connection.updated',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'fields' => $changedFields,
|
||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: (string) $user->email,
|
||||
actorName: (string) $user->name,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
|
||||
|
||||
unset(
|
||||
$state['verification_operation_run_id'],
|
||||
$state['bootstrap_operation_runs'],
|
||||
$state['bootstrap_operation_types'],
|
||||
);
|
||||
|
||||
$state['connection_recently_updated'] = true;
|
||||
|
||||
$this->onboardingSession->state = array_merge($state, [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
||||
$this->onboardingSession->save();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Connection updated')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->initializeWizardData();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -8,12 +8,15 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -86,6 +89,87 @@ public function handle(
|
||||
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$permissionService = app(TenantPermissionService::class);
|
||||
|
||||
$graphOptions = null;
|
||||
|
||||
if ($result->healthy) {
|
||||
try {
|
||||
$graphOptions = app(ProviderGateway::class)->graphOptions($connection);
|
||||
} catch (\Throwable) {
|
||||
$graphOptions = null;
|
||||
}
|
||||
}
|
||||
|
||||
$permissionComparison = $result->healthy
|
||||
? ($graphOptions === null
|
||||
? $permissionService->compare(
|
||||
$tenant,
|
||||
persist: false,
|
||||
liveCheck: false,
|
||||
useConfiguredStub: false,
|
||||
)
|
||||
: $permissionService->compare(
|
||||
$tenant,
|
||||
persist: true,
|
||||
liveCheck: true,
|
||||
useConfiguredStub: false,
|
||||
graphOptions: $graphOptions,
|
||||
))
|
||||
: $permissionService->compare(
|
||||
$tenant,
|
||||
persist: false,
|
||||
liveCheck: false,
|
||||
useConfiguredStub: false,
|
||||
);
|
||||
|
||||
$permissionRows = $permissionComparison['permissions'] ?? [];
|
||||
$permissionRows = is_array($permissionRows) ? $permissionRows : [];
|
||||
|
||||
$inventory = null;
|
||||
|
||||
if (! $result->healthy) {
|
||||
$inventory = [
|
||||
'fresh' => false,
|
||||
'reason_code' => $result->reasonCode ?? 'dependency_unreachable',
|
||||
'message' => 'Provider connection check failed; permissions were not refreshed during this run.',
|
||||
];
|
||||
} elseif ($graphOptions === null) {
|
||||
$inventory = [
|
||||
'fresh' => false,
|
||||
'reason_code' => 'provider_credential_missing',
|
||||
'message' => 'Provider credentials were unavailable; observed permissions inventory was not refreshed during this run.',
|
||||
];
|
||||
} else {
|
||||
$liveCheck = $permissionComparison['live_check'] ?? null;
|
||||
$liveCheck = is_array($liveCheck) ? $liveCheck : [];
|
||||
|
||||
$reasonCode = is_string($liveCheck['reason_code'] ?? null) ? (string) $liveCheck['reason_code'] : 'dependency_unreachable';
|
||||
$appId = is_string($liveCheck['app_id'] ?? null) && $liveCheck['app_id'] !== '' ? (string) $liveCheck['app_id'] : null;
|
||||
$observedCount = is_numeric($liveCheck['observed_permissions_count'] ?? null)
|
||||
? (int) $liveCheck['observed_permissions_count']
|
||||
: null;
|
||||
|
||||
$message = ($liveCheck['succeeded'] ?? false) === true
|
||||
? 'Observed permissions inventory refreshed successfully.'
|
||||
: match ($reasonCode) {
|
||||
'permissions_inventory_empty' => $appId !== null
|
||||
? sprintf('No application permissions were detected for app id %s. Verify admin consent was granted for this exact app registration, then retry verification.', $appId)
|
||||
: 'No application permissions were detected. Verify admin consent was granted for the configured app registration, then retry verification.',
|
||||
default => 'Unable to refresh observed permissions inventory during this run. Retry verification.',
|
||||
};
|
||||
|
||||
$inventory = [
|
||||
'fresh' => ($liveCheck['succeeded'] ?? false) === true,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message,
|
||||
'app_id' => $appId,
|
||||
'observed_permissions_count' => $observedCount,
|
||||
];
|
||||
}
|
||||
|
||||
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
|
||||
|
||||
$report = VerificationReportWriter::write(
|
||||
run: $this->operationRun,
|
||||
checks: [
|
||||
@ -124,6 +208,7 @@ public function handle(
|
||||
], tenant: $tenant),
|
||||
]],
|
||||
],
|
||||
...$permissionChecks,
|
||||
],
|
||||
identity: [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
|
||||
@ -30,6 +30,7 @@ class TenantOnboardingSession extends Model
|
||||
'bootstrap_operation_types',
|
||||
'bootstrap_operation_runs',
|
||||
'bootstrap_run_ids',
|
||||
'connection_recently_updated',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@ -587,7 +587,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
action: 'get_service_principal_permissions',
|
||||
response: $assignmentsResponse,
|
||||
transform: function (array $json) use ($context) {
|
||||
$assignments = $json['value'] ?? [];
|
||||
$assignments = is_array($json['value'] ?? null) ? $json['value'] : [];
|
||||
$assignmentsTotal = count($assignments);
|
||||
$permissions = [];
|
||||
|
||||
// Get Microsoft Graph service principal to map role IDs to permission names
|
||||
@ -605,22 +606,49 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
$graphSps = $graphSpResponse instanceof Response
|
||||
? $graphSpResponse->json('value', [])
|
||||
: [];
|
||||
$appRoles = $graphSps[0]['appRoles'] ?? [];
|
||||
$appRoles = is_array($graphSps[0]['appRoles'] ?? null) ? $graphSps[0]['appRoles'] : [];
|
||||
|
||||
// Map role IDs to permission names
|
||||
$roleMap = [];
|
||||
foreach ($appRoles as $role) {
|
||||
$roleMap[$role['id']] = $role['value'];
|
||||
$roleId = $role['id'] ?? null;
|
||||
$value = $role['value'] ?? null;
|
||||
|
||||
if (! is_string($roleId) || $roleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleMap[strtolower($roleId)] = $value;
|
||||
}
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$roleId = $assignment['appRoleId'] ?? null;
|
||||
if ($roleId && isset($roleMap[$roleId])) {
|
||||
$permissions[] = $roleMap[$roleId];
|
||||
|
||||
if (! is_string($roleId) || $roleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoleId = strtolower($roleId);
|
||||
|
||||
if (isset($roleMap[$normalizedRoleId])) {
|
||||
$permissions[] = $roleMap[$normalizedRoleId];
|
||||
}
|
||||
}
|
||||
|
||||
return ['permissions' => $permissions];
|
||||
$permissions = array_values(array_unique($permissions));
|
||||
|
||||
return [
|
||||
'permissions' => $permissions,
|
||||
'diagnostics' => [
|
||||
'assignments_total' => $assignmentsTotal,
|
||||
'mapped_total' => count($permissions),
|
||||
'graph_roles_total' => count($roleMap),
|
||||
],
|
||||
];
|
||||
},
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
|
||||
@ -40,27 +40,79 @@ public function getGrantedPermissions(Tenant $tenant): array
|
||||
* @param bool $persist Persist comparison results to tenant_permissions
|
||||
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
||||
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
||||
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
|
||||
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
|
||||
* @return array{
|
||||
* overall_status:string,
|
||||
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
||||
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
||||
* }
|
||||
*/
|
||||
public function compare(
|
||||
Tenant $tenant,
|
||||
?array $grantedStatuses = null,
|
||||
bool $persist = true,
|
||||
bool $liveCheck = false,
|
||||
bool $useConfiguredStub = true
|
||||
bool $useConfiguredStub = true,
|
||||
?array $graphOptions = null,
|
||||
): array {
|
||||
$required = $this->getRequiredPermissions();
|
||||
$liveCheckMeta = [
|
||||
'attempted' => false,
|
||||
'succeeded' => false,
|
||||
'http_status' => null,
|
||||
'reason_code' => null,
|
||||
];
|
||||
|
||||
$liveCheckFailed = false;
|
||||
$liveCheckDetails = null;
|
||||
|
||||
// If liveCheck is requested, fetch actual permissions from Graph
|
||||
if ($liveCheck && $grantedStatuses === null) {
|
||||
$grantedStatuses = $this->fetchLivePermissions($tenant);
|
||||
$liveCheckMeta['attempted'] = true;
|
||||
|
||||
$appId = null;
|
||||
if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
|
||||
$appId = (string) $graphOptions['client_id'];
|
||||
} elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') {
|
||||
$appId = (string) $tenant->graphOptions()['client_id'];
|
||||
}
|
||||
|
||||
if ($appId !== null) {
|
||||
$liveCheckMeta['app_id'] = $appId;
|
||||
}
|
||||
|
||||
$grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions);
|
||||
|
||||
if (isset($grantedStatuses['__error'])) {
|
||||
$liveCheckFailed = true;
|
||||
$liveCheckDetails = $grantedStatuses['__error']['details'] ?? null;
|
||||
$liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null;
|
||||
$liveCheckDetails = is_array($liveCheckError['details'] ?? null)
|
||||
? $liveCheckError['details']
|
||||
: (is_array($liveCheckError) ? $liveCheckError : null);
|
||||
|
||||
$httpStatus = $liveCheckDetails['status'] ?? null;
|
||||
$liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null;
|
||||
$liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode(
|
||||
$liveCheckMeta['http_status'],
|
||||
is_array($liveCheckDetails) ? $liveCheckDetails : null,
|
||||
);
|
||||
|
||||
unset($grantedStatuses['__error']);
|
||||
$grantedStatuses = null;
|
||||
} else {
|
||||
$observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0;
|
||||
$liveCheckMeta['observed_permissions_count'] = $observedCount;
|
||||
|
||||
if ($observedCount === 0) {
|
||||
// Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh.
|
||||
// This prevents false "missing" findings due to partial/misconfigured verification context.
|
||||
$liveCheckMeta['succeeded'] = false;
|
||||
$liveCheckMeta['reason_code'] = 'permissions_inventory_empty';
|
||||
$grantedStatuses = null;
|
||||
} else {
|
||||
$liveCheckMeta['succeeded'] = true;
|
||||
$liveCheckMeta['reason_code'] = 'ok';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,16 +133,29 @@ public function compare(
|
||||
$hasErrors = false;
|
||||
$checkedAt = now();
|
||||
|
||||
$canPersist = $persist;
|
||||
|
||||
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||
$canPersist = false;
|
||||
}
|
||||
|
||||
foreach ($required as $permission) {
|
||||
$key = $permission['key'];
|
||||
$status = $liveCheckFailed
|
||||
? 'error'
|
||||
: ($granted[$key]['status'] ?? 'missing');
|
||||
|
||||
$details = $liveCheckFailed
|
||||
? ($liveCheckDetails ?? ['source' => 'graph_api'])
|
||||
? array_filter([
|
||||
'source' => 'graph_api',
|
||||
'status' => $liveCheckMeta['http_status'],
|
||||
'reason_code' => $liveCheckMeta['reason_code'],
|
||||
'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null,
|
||||
], fn (mixed $value): bool => $value !== null)
|
||||
: ($granted[$key]['details'] ?? null);
|
||||
|
||||
if ($persist) {
|
||||
if ($canPersist) {
|
||||
TenantPermission::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@ -123,10 +188,36 @@ public function compare(
|
||||
default => 'granted',
|
||||
};
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'overall_status' => $overall,
|
||||
'permissions' => $results,
|
||||
];
|
||||
|
||||
if ($liveCheckMeta['attempted'] === true) {
|
||||
$payload['live_check'] = $liveCheckMeta;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $details
|
||||
*/
|
||||
private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string
|
||||
{
|
||||
if (is_array($details) && is_string($details['reason_code'] ?? null)) {
|
||||
return (string) $details['reason_code'];
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$httpStatus === 401 => 'authentication_failed',
|
||||
$httpStatus === 403 => 'permission_denied',
|
||||
$httpStatus === 408 => 'dependency_unreachable',
|
||||
$httpStatus === 429 => 'throttled',
|
||||
is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable',
|
||||
is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error',
|
||||
default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -211,11 +302,11 @@ private function configuredGrantedKeys(): array
|
||||
*
|
||||
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
||||
*/
|
||||
private function fetchLivePermissions(Tenant $tenant): array
|
||||
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
|
||||
{
|
||||
try {
|
||||
$response = $this->graphClient->getServicePrincipalPermissions(
|
||||
$tenant->graphOptions()
|
||||
$graphOptions ?? $tenant->graphOptions()
|
||||
);
|
||||
|
||||
if (! $response->success) {
|
||||
@ -232,6 +323,25 @@ private function fetchLivePermissions(Tenant $tenant): array
|
||||
}
|
||||
|
||||
$grantedPermissions = $response->data['permissions'] ?? [];
|
||||
$diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null;
|
||||
$assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0;
|
||||
$mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null;
|
||||
|
||||
if ($assignmentsTotal > 0 && $mappedTotal === 0) {
|
||||
return [
|
||||
'__error' => [
|
||||
'status' => 'error',
|
||||
'details' => [
|
||||
'source' => 'graph_api',
|
||||
'status' => $response->status,
|
||||
'reason_code' => 'permission_mapping_failed',
|
||||
'message' => 'Graph returned app role assignments, but the system could not map them to permission values.',
|
||||
'diagnostics' => $diagnostics,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($grantedPermissions as $permission) {
|
||||
|
||||
@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
class TenantRequiredPermissionsViewModelBuilder
|
||||
{
|
||||
/**
|
||||
* @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
|
||||
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
|
||||
* @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
|
||||
* @phpstan-type ViewModel array{
|
||||
* tenant: array{id:int,external_id:string,name:string},
|
||||
* overview: array{
|
||||
* overall: string,
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* feature_impacts: array<int, FeatureImpact>
|
||||
* },
|
||||
* permissions: array<int, TenantPermissionRow>,
|
||||
* filters: FilterState,
|
||||
* copy: array{application:string,delegated:string}
|
||||
* }
|
||||
*/
|
||||
public function __construct(private readonly TenantPermissionService $permissionService) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return ViewModel
|
||||
*/
|
||||
public function build(Tenant $tenant, array $filters = []): array
|
||||
{
|
||||
$comparison = $this->permissionService->compare(
|
||||
$tenant,
|
||||
persist: false,
|
||||
liveCheck: false,
|
||||
useConfiguredStub: false,
|
||||
);
|
||||
|
||||
/** @var array<int, TenantPermissionRow> $allPermissions */
|
||||
$allPermissions = collect($comparison['permissions'] ?? [])
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$state = self::normalizeFilterState($filters);
|
||||
|
||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||
|
||||
return [
|
||||
'tenant' => [
|
||||
'id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) $tenant->external_id,
|
||||
'name' => (string) $tenant->name,
|
||||
],
|
||||
'overview' => [
|
||||
'overall' => self::deriveOverallStatus($allPermissions),
|
||||
'counts' => self::deriveCounts($allPermissions),
|
||||
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||
],
|
||||
'permissions' => $filteredPermissions,
|
||||
'filters' => $state,
|
||||
'copy' => [
|
||||
'application' => self::deriveCopyPayload($allPermissions, 'application', $state['features']),
|
||||
'delegated' => self::deriveCopyPayload($allPermissions, 'delegated', $state['features']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
*/
|
||||
public static function deriveOverallStatus(array $permissions): string
|
||||
{
|
||||
$hasMissingApplication = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||
);
|
||||
|
||||
if ($hasMissingApplication) {
|
||||
return VerificationReportOverall::Blocked->value;
|
||||
}
|
||||
|
||||
$hasErrors = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'error',
|
||||
);
|
||||
|
||||
$hasMissingDelegated = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||
);
|
||||
|
||||
if ($hasErrors || $hasMissingDelegated) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
return VerificationReportOverall::Ready->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
||||
*/
|
||||
public static function deriveCounts(array $permissions): array
|
||||
{
|
||||
$counts = [
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 0,
|
||||
'error' => 0,
|
||||
];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$counts['missing_delegated'] += 1;
|
||||
} else {
|
||||
$counts['missing_application'] += 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'granted') {
|
||||
$counts['present'] += 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'error') {
|
||||
$counts['error'] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, FeatureImpact>
|
||||
*/
|
||||
public static function deriveFeatureImpacts(array $permissions): array
|
||||
{
|
||||
/** @var array<string, FeatureImpact> $impacts */
|
||||
$impacts = [];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
$features = array_values(array_unique($row['features'] ?? []));
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if (! isset($impacts[$feature])) {
|
||||
$impacts[$feature] = [
|
||||
'feature' => $feature,
|
||||
'missing' => 0,
|
||||
'required_application' => 0,
|
||||
'required_delegated' => 0,
|
||||
'blocked' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$impacts[$feature]['required_delegated'] += 1;
|
||||
} else {
|
||||
$impacts[$feature]['required_application'] += 1;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
$impacts[$feature]['missing'] += 1;
|
||||
|
||||
if (($row['type'] ?? null) === 'application') {
|
||||
$impacts[$feature]['blocked'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$values = array_values($impacts);
|
||||
|
||||
usort($values, static function (array $a, array $b): int {
|
||||
$blocked = (int) ($b['blocked'] <=> $a['blocked']);
|
||||
if ($blocked !== 0) {
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
$missing = (int) (($b['missing'] ?? 0) <=> ($a['missing'] ?? 0));
|
||||
if ($missing !== 0) {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['feature'] ?? ''), (string) ($b['feature'] ?? ''));
|
||||
});
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy payload semantics:
|
||||
* - Always Missing-only
|
||||
* - Always Type fixed by button (application vs delegated)
|
||||
* - Respects Feature filter only
|
||||
* - Ignores Search
|
||||
*
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
* @param array<int, string> $featureFilter
|
||||
*/
|
||||
public static function deriveCopyPayload(array $permissions, string $type, array $featureFilter = []): string
|
||||
{
|
||||
$featureFilter = array_values(array_unique(array_filter(array_map('strval', $featureFilter))));
|
||||
|
||||
$payload = collect($permissions)
|
||||
->filter(function (array $row) use ($type, $featureFilter): bool {
|
||||
if (($row['status'] ?? null) !== 'missing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($featureFilter === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($featureFilter, $rowFeatures)) > 0;
|
||||
})
|
||||
->pluck('key')
|
||||
->map(fn (mixed $key): string => (string) $key)
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return implode("\n", $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, TenantPermissionRow>
|
||||
*/
|
||||
public static function applyFilterState(array $permissions, array $state): array
|
||||
{
|
||||
$status = $state['status'] ?? 'missing';
|
||||
$type = $state['type'] ?? 'all';
|
||||
$features = $state['features'] ?? [];
|
||||
$search = $state['search'] ?? '';
|
||||
|
||||
$search = is_string($search) ? trim($search) : '';
|
||||
$searchLower = strtolower($search);
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
$filtered = collect($permissions)
|
||||
->filter(function (array $row) use ($status, $type, $features): bool {
|
||||
$rowStatus = $row['status'] ?? null;
|
||||
$rowType = $row['type'] ?? null;
|
||||
|
||||
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status === 'present' && $rowStatus !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($type !== 'all' && $rowType !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($features === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($features, $rowFeatures)) > 0;
|
||||
})
|
||||
->when($searchLower !== '', function ($collection) use ($searchLower) {
|
||||
return $collection->filter(function (array $row) use ($searchLower): bool {
|
||||
$key = strtolower((string) ($row['key'] ?? ''));
|
||||
$description = strtolower((string) ($row['description'] ?? ''));
|
||||
|
||||
return str_contains($key, $searchLower) || ($description !== '' && str_contains($description, $searchLower));
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
usort($filtered, static function (array $a, array $b): int {
|
||||
$weight = static function (array $row): int {
|
||||
return match ($row['status'] ?? null) {
|
||||
'missing' => 0,
|
||||
'error' => 1,
|
||||
default => 2,
|
||||
};
|
||||
};
|
||||
|
||||
$cmp = $weight($a) <=> $weight($b);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
});
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return FilterState
|
||||
*/
|
||||
public static function normalizeFilterState(array $filters): array
|
||||
{
|
||||
$status = (string) ($filters['status'] ?? 'missing');
|
||||
$type = (string) ($filters['type'] ?? 'all');
|
||||
$features = $filters['features'] ?? [];
|
||||
$search = (string) ($filters['search'] ?? '');
|
||||
|
||||
if (! in_array($status, ['missing', 'present', 'all'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! in_array($type, ['application', 'delegated', 'all'], true)) {
|
||||
$type = 'all';
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'type' => $type,
|
||||
'features' => $features,
|
||||
'search' => $search,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return TenantPermissionRow
|
||||
*/
|
||||
private static function normalizePermissionRow(array $row): array
|
||||
{
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
$type = (string) ($row['type'] ?? 'application');
|
||||
$description = $row['description'] ?? null;
|
||||
$features = $row['features'] ?? [];
|
||||
$status = (string) ($row['status'] ?? 'missing');
|
||||
$details = $row['details'] ?? null;
|
||||
|
||||
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||
$type = 'application';
|
||||
}
|
||||
|
||||
if (! is_string($description) || $description === '') {
|
||||
$description = null;
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! is_array($details)) {
|
||||
$details = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'features' => $features,
|
||||
'status' => $status,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -74,4 +74,21 @@ public function upsertClientSecretCredential(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function updateClientIdPreservingSecret(ProviderConnection $connection, string $clientId): ProviderCredential
|
||||
{
|
||||
$clientId = trim($clientId);
|
||||
|
||||
if ($clientId === '') {
|
||||
throw new InvalidArgumentException('client_id is required.');
|
||||
}
|
||||
|
||||
$existing = $this->getClientCredentials($connection);
|
||||
|
||||
return $this->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
clientSecret: (string) $existing['client_secret'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Support/Links/RequiredPermissionsLinks.php
Normal file
42
app/Support/Links/RequiredPermissionsLinks.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Links;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
|
||||
final class RequiredPermissionsLinks
|
||||
{
|
||||
private const ADMIN_CONSENT_GUIDE_URL = 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
|
||||
{
|
||||
$base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id));
|
||||
|
||||
if ($filters === []) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$query = http_build_query($filters);
|
||||
|
||||
return $query !== '' ? "{$base}?{$query}" : $base;
|
||||
}
|
||||
|
||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
return TenantResource::adminConsentUrl($tenant);
|
||||
}
|
||||
|
||||
public static function adminConsentGuideUrl(): string
|
||||
{
|
||||
return self::ADMIN_CONSENT_GUIDE_URL;
|
||||
}
|
||||
|
||||
public static function adminConsentPrimaryUrl(Tenant $tenant): string
|
||||
{
|
||||
return self::adminConsentUrl($tenant) ?? self::adminConsentGuideUrl();
|
||||
}
|
||||
}
|
||||
415
app/Support/Verification/TenantPermissionCheckClusters.php
Normal file
415
app/Support/Verification/TenantPermissionCheckClusters.php
Normal file
@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
final class TenantPermissionCheckClusters
|
||||
{
|
||||
/**
|
||||
* @phpstan-type TenantPermissionRow array{
|
||||
* key:string,
|
||||
* type:'application'|'delegated',
|
||||
* description:?string,
|
||||
* features:array<int,string>,
|
||||
* status:'granted'|'missing'|'error',
|
||||
* details:array<string,mixed>|null
|
||||
* }
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $permissions
|
||||
* @param array{fresh?:bool,reason_code?:string,message?:string}|null $inventory
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function buildChecks(Tenant $tenant, array $permissions, ?array $inventory = null): array
|
||||
{
|
||||
$inventory = is_array($inventory) ? $inventory : [];
|
||||
|
||||
$inventoryFresh = $inventory['fresh'] ?? true;
|
||||
$inventoryFresh = is_bool($inventoryFresh) ? $inventoryFresh : true;
|
||||
|
||||
$inventoryReasonCode = $inventory['reason_code'] ?? null;
|
||||
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
|
||||
? $inventoryReasonCode
|
||||
: 'dependency_unreachable';
|
||||
|
||||
$inventoryMessage = $inventory['message'] ?? null;
|
||||
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
||||
? trim($inventoryMessage)
|
||||
: 'Unable to refresh observed permissions inventory during this run. Retry verification.';
|
||||
|
||||
$inventoryEvidence = self::inventoryEvidence($inventory);
|
||||
|
||||
/** @var array<int, TenantPermissionRow> $rows */
|
||||
$rows = collect($permissions)
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$checks = [];
|
||||
|
||||
foreach (self::definitions() as $definition) {
|
||||
$key = (string) ($definition['key'] ?? 'unknown');
|
||||
$title = (string) ($definition['title'] ?? 'Check');
|
||||
|
||||
$clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matches($definition, $row)));
|
||||
|
||||
$checks[] = self::buildCheck(
|
||||
tenant: $tenant,
|
||||
key: $key,
|
||||
title: $title,
|
||||
clusterRows: $clusterRows,
|
||||
inventoryFresh: $inventoryFresh,
|
||||
inventoryReasonCode: $inventoryReasonCode,
|
||||
inventoryMessage: $inventoryMessage,
|
||||
inventoryEvidence: $inventoryEvidence,
|
||||
);
|
||||
}
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,title:string,mode:string,prefixes?:array<int,string>,keys?:array<int,string>}>
|
||||
*/
|
||||
private static function definitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'mode' => 'type',
|
||||
'type' => 'application',
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.directory_groups',
|
||||
'title' => 'Directory & group read access',
|
||||
'mode' => 'keys',
|
||||
'keys' => [
|
||||
'Directory.Read.All',
|
||||
'Group.Read.All',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_configuration',
|
||||
'title' => 'Intune configuration access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementConfiguration.',
|
||||
'DeviceManagementServiceConfig.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_apps',
|
||||
'title' => 'Intune apps access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementApps.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_rbac_assignments',
|
||||
'title' => 'Intune RBAC & assignments prerequisites',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementRBAC.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.scripts_remediations',
|
||||
'title' => 'Scripts/remediations access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementScripts.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{mode:string,prefixes?:array<int,string>,keys?:array<int,string>,type?:string} $definition
|
||||
* @param TenantPermissionRow $row
|
||||
*/
|
||||
private static function matches(array $definition, array $row): bool
|
||||
{
|
||||
$mode = (string) ($definition['mode'] ?? '');
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
|
||||
if ($mode === 'type') {
|
||||
return ($row['type'] ?? null) === ($definition['type'] ?? null);
|
||||
}
|
||||
|
||||
if ($mode === 'keys') {
|
||||
$keys = $definition['keys'] ?? [];
|
||||
|
||||
return is_array($keys) && in_array($key, $keys, true);
|
||||
}
|
||||
|
||||
if ($mode === 'prefixes') {
|
||||
$prefixes = $definition['prefixes'] ?? [];
|
||||
|
||||
if (! is_array($prefixes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (is_string($prefix) && $prefix !== '' && str_starts_with($key, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $clusterRows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildCheck(
|
||||
Tenant $tenant,
|
||||
string $key,
|
||||
string $title,
|
||||
array $clusterRows,
|
||||
bool $inventoryFresh,
|
||||
string $inventoryReasonCode,
|
||||
string $inventoryMessage,
|
||||
array $inventoryEvidence,
|
||||
): array
|
||||
{
|
||||
if (! $inventoryFresh) {
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Warn->value,
|
||||
'severity' => VerificationCheckSeverity::Medium->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => $inventoryReasonCode,
|
||||
'message' => $inventoryMessage,
|
||||
'evidence' => $inventoryEvidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($clusterRows === []) {
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Skip->value,
|
||||
'severity' => VerificationCheckSeverity::Info->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'not_applicable',
|
||||
'message' => 'Not applicable for this tenant.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$missingApplication = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||
));
|
||||
|
||||
$missingDelegated = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||
));
|
||||
|
||||
$errored = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'error',
|
||||
));
|
||||
|
||||
$evidence = array_values(array_unique(array_merge(
|
||||
self::evidence($missingApplication, $missingDelegated, $errored),
|
||||
$inventoryEvidence,
|
||||
), SORT_REGULAR));
|
||||
|
||||
if ($missingApplication !== [] || $errored !== []) {
|
||||
$missingKeys = array_values(array_unique(array_merge(
|
||||
array_map(static fn (array $row): string => $row['key'], $missingApplication),
|
||||
array_map(static fn (array $row): string => $row['key'], $errored),
|
||||
)));
|
||||
|
||||
$message = $missingKeys !== []
|
||||
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
|
||||
: 'Missing required permissions.';
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Fail->value,
|
||||
'severity' => VerificationCheckSeverity::Critical->value,
|
||||
'blocking' => true,
|
||||
'reason_code' => 'ext.missing_permission',
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($missingDelegated !== []) {
|
||||
$missingKeys = array_values(array_unique(array_map(static fn (array $row): string => $row['key'], $missingDelegated)));
|
||||
$message = sprintf('Missing delegated permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)));
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Warn->value,
|
||||
'severity' => VerificationCheckSeverity::Medium->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ext.missing_delegated_permission',
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Pass->value,
|
||||
'severity' => VerificationCheckSeverity::Info->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'All required permissions are granted.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $missingApplication
|
||||
* @param array<int, TenantPermissionRow> $missingDelegated
|
||||
* @param array<int, TenantPermissionRow> $errored
|
||||
* @return array<int, array{kind:string,value:int|string}>
|
||||
*/
|
||||
private static function evidence(array $missingApplication, array $missingDelegated, array $errored): array
|
||||
{
|
||||
$pointers = [];
|
||||
|
||||
foreach (array_merge($missingApplication, $missingDelegated, $errored) as $row) {
|
||||
$pointers[] = [
|
||||
'kind' => 'missing_permission',
|
||||
'value' => (string) ($row['key'] ?? ''),
|
||||
];
|
||||
|
||||
$pointers[] = [
|
||||
'kind' => 'permission_type',
|
||||
'value' => (string) ($row['type'] ?? 'application'),
|
||||
];
|
||||
|
||||
foreach (($row['features'] ?? []) as $feature) {
|
||||
if (! is_string($feature) || $feature === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pointers[] = [
|
||||
'kind' => 'feature',
|
||||
'value' => $feature,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$unique = [];
|
||||
|
||||
foreach ($pointers as $pointer) {
|
||||
$key = $pointer['kind'].':'.(string) $pointer['value'];
|
||||
$unique[$key] = $pointer;
|
||||
}
|
||||
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inventory
|
||||
* @return array<int, array{kind:string,value:int|string}>
|
||||
*/
|
||||
private static function inventoryEvidence(array $inventory): array
|
||||
{
|
||||
$pointers = [];
|
||||
|
||||
$appId = $inventory['app_id'] ?? null;
|
||||
if (is_string($appId) && $appId !== '') {
|
||||
$pointers[] = [
|
||||
'kind' => 'app_id',
|
||||
'value' => $appId,
|
||||
];
|
||||
}
|
||||
|
||||
$observedCount = $inventory['observed_permissions_count'] ?? null;
|
||||
if (is_int($observedCount) || (is_numeric($observedCount) && (string) (int) $observedCount === (string) $observedCount)) {
|
||||
$pointers[] = [
|
||||
'kind' => 'observed_permissions_count',
|
||||
'value' => (int) $observedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $pointers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return TenantPermissionRow
|
||||
*/
|
||||
private static function normalizePermissionRow(array $row): array
|
||||
{
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
$type = (string) ($row['type'] ?? 'application');
|
||||
$status = (string) ($row['status'] ?? 'missing');
|
||||
$description = $row['description'] ?? null;
|
||||
$features = $row['features'] ?? [];
|
||||
$details = $row['details'] ?? null;
|
||||
|
||||
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||
$type = 'application';
|
||||
}
|
||||
|
||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! is_string($description) || $description === '') {
|
||||
$description = null;
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
if (! is_array($details)) {
|
||||
$details = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'features' => $features,
|
||||
'status' => $status,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,23 @@ final class VerificationReportSanitizer
|
||||
'set-cookie',
|
||||
];
|
||||
|
||||
/**
|
||||
* Evidence pointers must remain pointer-only. This allowlist is intentionally strict.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ALLOWED_EVIDENCE_KINDS = [
|
||||
'provider_connection_id',
|
||||
'entra_tenant_id',
|
||||
'organization_id',
|
||||
'http_status',
|
||||
'app_id',
|
||||
'observed_permissions_count',
|
||||
'missing_permission',
|
||||
'permission_type',
|
||||
'feature',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -210,6 +227,12 @@ private static function sanitizeEvidence(array $evidence): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = trim($kind);
|
||||
|
||||
if (! in_array($kind, self::ALLOWED_EVIDENCE_KINDS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($kind)) {
|
||||
continue;
|
||||
}
|
||||
@ -217,7 +240,7 @@ private static function sanitizeEvidence(array $evidence): array
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $value];
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -232,7 +255,7 @@ private static function sanitizeEvidence(array $evidence): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$verificationReport = $verification_report ?? null;
|
||||
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
@ -31,6 +34,43 @@
|
||||
$completedAtLabel = $completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
$checks = is_array($verificationReport['checks'] ?? null) ? $verificationReport['checks'] : [];
|
||||
$summaryOverall = is_array($verificationReport['summary'] ?? null) ? ($verificationReport['summary']['overall'] ?? null) : null;
|
||||
$summaryOverall = is_string($summaryOverall) ? $summaryOverall : null;
|
||||
|
||||
$sortWeight = static function (array $check): int {
|
||||
$status = is_array($check) ? ($check['status'] ?? null) : null;
|
||||
$blocking = is_array($check) ? ($check['blocking'] ?? false) : false;
|
||||
|
||||
if ($status === 'fail' && $blocking === true) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($status === 'fail') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($status === 'warn') {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($status === 'running') {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if ($status === 'skip') {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if ($status === 'pass') {
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 6;
|
||||
};
|
||||
|
||||
$hasReport = $verificationReport !== null;
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@ -45,49 +85,195 @@
|
||||
</div>
|
||||
@elseif ($status !== 'completed')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
||||
</div>
|
||||
@elseif ($outcome === 'succeeded')
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
All verification checks passed.
|
||||
</div>
|
||||
@elseif ($failures === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable. The run completed, but no failure details were recorded.
|
||||
@if (! $hasReport)
|
||||
No report yet. Use “Refresh results” to update stored status.
|
||||
@else
|
||||
Partial results available. Use “Refresh results” to update stored status.
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings
|
||||
@php
|
||||
$sortedChecks = collect($checks)
|
||||
->filter(fn ($check) => is_array($check))
|
||||
->sortBy(fn (array $check) => $sortWeight($check))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$issueChecks = collect($sortedChecks)
|
||||
->filter(fn (array $check) => in_array((string) ($check['status'] ?? ''), ['fail', 'warn', 'running'], true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$otherChecks = collect($sortedChecks)
|
||||
->filter(fn (array $check) => ! in_array((string) ($check['status'] ?? ''), ['fail', 'warn', 'running'], true))
|
||||
->values()
|
||||
->all();
|
||||
@endphp
|
||||
|
||||
@if ($summaryOverall !== null)
|
||||
@php
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::VerificationReportOverall, $summaryOverall);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Overall</div>
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon" size="sm">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($failures as $failure)
|
||||
@php
|
||||
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
|
||||
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
|
||||
@if ($sortedChecks === [])
|
||||
@if ($outcome === 'succeeded')
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
All verification checks passed.
|
||||
</div>
|
||||
@elseif ($failures === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable. The run completed, but no failure details were recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings
|
||||
</div>
|
||||
|
||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
||||
$message = is_string($message) && $message !== '' ? $message : null;
|
||||
@endphp
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($failures as $failure)
|
||||
@php
|
||||
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
|
||||
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
|
||||
|
||||
@if ($reasonCode !== null || $message !== null)
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
@if ($reasonCode !== null)
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $reasonCode }}
|
||||
</div>
|
||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
||||
$message = is_string($message) && $message !== '' ? $message : null;
|
||||
@endphp
|
||||
|
||||
@if ($reasonCode !== null || $message !== null)
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
@if ($reasonCode !== null)
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $reasonCode }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($message !== null)
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@if ($message !== null)
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
@if ($issueChecks !== [])
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Issues
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($issueChecks as $check)
|
||||
@php
|
||||
$title = (string) ($check['title'] ?? 'Check');
|
||||
$status = (string) ($check['status'] ?? 'fail');
|
||||
$message = (string) ($check['message'] ?? '—');
|
||||
$reasonCode = (string) ($check['reason_code'] ?? '');
|
||||
$nextSteps = is_array($check['next_steps'] ?? null) ? $check['next_steps'] : [];
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::VerificationCheckStatus, $status);
|
||||
@endphp
|
||||
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($reasonCode !== '')
|
||||
<div class="mt-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $reasonCode }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$label = is_array($step) ? ($step['label'] ?? null) : null;
|
||||
$url = is_array($step) ? ($step['url'] ?? null) : null;
|
||||
|
||||
$label = is_string($label) && $label !== '' ? $label : null;
|
||||
$url = is_string($url) && $url !== '' ? $url : null;
|
||||
@endphp
|
||||
|
||||
@if ($label !== null && $url !== null)
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
All verification checks passed.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($otherChecks !== [])
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-white">
|
||||
View all checks
|
||||
</summary>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
@foreach ($otherChecks as $check)
|
||||
@php
|
||||
$title = (string) ($check['title'] ?? 'Check');
|
||||
$status = (string) ($check['status'] ?? 'skip');
|
||||
$message = (string) ($check['message'] ?? '—');
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::VerificationCheckStatus, $status);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $message }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($targetScope !== [])
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
@php
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
$outcome = $run['outcome'] ?? null;
|
||||
$outcome = is_string($outcome) ? $outcome : null;
|
||||
|
||||
$startedAt = $run['started_at'] ?? null;
|
||||
$startedAt = is_string($startedAt) && $startedAt !== '' ? $startedAt : null;
|
||||
|
||||
$updatedAt = $run['updated_at'] ?? null;
|
||||
$updatedAt = is_string($updatedAt) && $updatedAt !== '' ? $updatedAt : null;
|
||||
|
||||
$completedAt = $run['completed_at'] ?? null;
|
||||
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
|
||||
|
||||
$hasReport = $hasReport ?? false;
|
||||
$hasReport = is_bool($hasReport) ? $hasReport : false;
|
||||
|
||||
$formatTs = static function (?string $ts): ?string {
|
||||
if ($ts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return \Carbon\CarbonImmutable::parse($ts)->format('Y-m-d H:i');
|
||||
} catch (\Throwable) {
|
||||
return $ts;
|
||||
}
|
||||
};
|
||||
|
||||
$relativeTs = static function (?string $ts): ?string {
|
||||
if ($ts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return \Carbon\CarbonImmutable::parse($ts)->diffForHumans(null, true, true);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$targetScope = $run['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
||||
|
||||
$runStatusSpec = null;
|
||||
|
||||
if ($status !== null) {
|
||||
$runStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, $status);
|
||||
}
|
||||
|
||||
$runOutcomeSpec = null;
|
||||
|
||||
if ($outcome !== null && $status === 'completed') {
|
||||
$runOutcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, $outcome);
|
||||
}
|
||||
|
||||
$workerHint = match ($status) {
|
||||
'queued' => 'Awaiting worker',
|
||||
'running' => 'Worker running',
|
||||
default => null,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($run === null)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No verification run has been started yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Run #{{ (int) ($run['id'] ?? 0) }}
|
||||
</div>
|
||||
|
||||
@if ($runStatusSpec)
|
||||
<x-filament::badge :color="$runStatusSpec->color" :icon="$runStatusSpec->icon" size="sm">
|
||||
{{ $runStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($runOutcomeSpec)
|
||||
<x-filament::badge :color="$runOutcomeSpec->color" :icon="$runOutcomeSpec->icon" size="sm">
|
||||
{{ $runOutcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($status !== 'completed')
|
||||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
@if ($workerHint)
|
||||
<div>{{ $workerHint }}.</div>
|
||||
@endif
|
||||
|
||||
@if (! $hasReport)
|
||||
<div>No report yet. Refresh results in a moment.</div>
|
||||
@else
|
||||
<div>Partial results available. Use “Refresh results” to update the stored status in the wizard.</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Operation</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-white">{{ (string) ($run['type'] ?? '—') }}</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope (Entra tenant)</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-white">{{ $entraTenantId ?? '—' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Timestamps</div>
|
||||
<dl class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||
<dd class="text-right">{{ $formatTs($startedAt) ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Last update</dt>
|
||||
<dd class="text-right">
|
||||
{{ $formatTs($updatedAt) ?? '—' }}
|
||||
@if ($updatedAt !== null && ($relativeTs($updatedAt) !== null))
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $relativeTs($updatedAt) }} ago)</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Completed</dt>
|
||||
<dd class="text-right">{{ $formatTs($completedAt) ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($runUrl)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $runUrl }}"
|
||||
class="text-sm font-medium text-gray-600 hover:underline dark:text-gray-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open run in Monitoring (advanced)
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,513 @@
|
||||
@php
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
||||
|
||||
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
||||
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
||||
$selectedStatus = (string) ($filters['status'] ?? 'missing');
|
||||
$selectedType = (string) ($filters['type'] ?? 'all');
|
||||
$searchTerm = (string) ($filters['search'] ?? '');
|
||||
|
||||
$featureOptions = collect($featureImpacts)
|
||||
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
|
||||
->map(fn (array $impact): string => (string) $impact['feature'])
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
|
||||
|
||||
$overall = $overview['overall'] ?? null;
|
||||
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
|
||||
|
||||
$copy = is_array($vm['copy'] ?? null) ? $vm['copy'] : [];
|
||||
$copyApplication = (string) ($copy['application'] ?? '');
|
||||
$copyDelegated = (string) ($copy['delegated'] ?? '');
|
||||
|
||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||
$presentCount = (int) ($counts['present'] ?? 0);
|
||||
$errorCount = (int) ($counts['error'] ?? 0);
|
||||
|
||||
$missingTotal = $missingApplication + $missingDelegated + $errorCount;
|
||||
$requiredTotal = $missingTotal + $presentCount;
|
||||
|
||||
$adminConsentUrl = $tenant ? RequiredPermissionsLinks::adminConsentUrl($tenant) : null;
|
||||
$adminConsentPrimaryUrl = $tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) : RequiredPermissionsLinks::adminConsentGuideUrl();
|
||||
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
|
||||
|
||||
$reRunUrl = $this->reRunVerificationUrl();
|
||||
@endphp
|
||||
|
||||
<x-filament::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review what’s missing for this tenant and copy the missing permissions for admin consent.
|
||||
</div>
|
||||
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (app)</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (delegated)</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Present</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['present'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Errors</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['error'] ?? 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div>
|
||||
<span class="font-medium">Who can fix this?</span>
|
||||
Global Administrator / Privileged Role Administrator.
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Primary next step:</span>
|
||||
<a
|
||||
href="{{ $adminConsentPrimaryUrl }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ $adminConsentLabel }}
|
||||
</a>
|
||||
</div>
|
||||
@if ($reRunUrl)
|
||||
<div>
|
||||
<span class="font-medium">After granting consent:</span>
|
||||
<a
|
||||
href="{{ $reRunUrl }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Re-run verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-filament::button
|
||||
color="primary"
|
||||
size="sm"
|
||||
x-on:click="showCopyApplication = true"
|
||||
:disabled="$copyApplication === ''"
|
||||
>
|
||||
Copy missing application permissions
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
size="sm"
|
||||
x-on:click="showCopyDelegated = true"
|
||||
:disabled="$copyDelegated === ''"
|
||||
>
|
||||
Copy missing delegated permissions
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (is_array($featureImpacts) && $featureImpacts !== [])
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($featureImpacts as $impact)
|
||||
@php
|
||||
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
|
||||
$featureKey = is_string($featureKey) ? $featureKey : null;
|
||||
$missingCount = is_array($impact) ? (int) ($impact['missing'] ?? 0) : 0;
|
||||
$isBlocked = is_array($impact) ? (bool) ($impact['blocked'] ?? false) : false;
|
||||
|
||||
if ($featureKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selected = in_array($featureKey, $selectedFeatures, true);
|
||||
@endphp
|
||||
|
||||
<button
|
||||
type="button"
|
||||
wire:click="applyFeatureFilter(@js($featureKey))"
|
||||
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $featureKey }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $missingCount }} missing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$isBlocked ? 'danger' : ($missingCount > 0 ? 'warning' : 'success')" size="sm">
|
||||
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($selectedFeatures !== [])
|
||||
<div>
|
||||
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
|
||||
Clear feature filter
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showCopyApplication"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
|
||||
x-on:keydown.escape.window="showCopyApplication = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyApplication = false"></div>
|
||||
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing application permissions</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for admin consent.</div>
|
||||
</div>
|
||||
<x-filament::button color="gray" size="sm" x-on:click="showCopyApplication = false">Close</x-filament::button>
|
||||
</div>
|
||||
|
||||
@if ($copyApplication === '')
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
Nothing to copy — no missing application permissions in the current feature filter.
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="mt-4 space-y-2"
|
||||
x-data="{
|
||||
text: @js($copyApplication),
|
||||
copied: false,
|
||||
async copyPayload() {
|
||||
try {
|
||||
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
||||
await navigator.clipboard.writeText(this.text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.inset = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
this.copied = true;
|
||||
setTimeout(() => (this.copied = false), 1500);
|
||||
} catch (e) {
|
||||
this.copied = false;
|
||||
}
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Copied
|
||||
</span>
|
||||
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
|
||||
Copy
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||||
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showCopyDelegated"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
|
||||
x-on:keydown.escape.window="showCopyDelegated = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyDelegated = false"></div>
|
||||
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing delegated permissions</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for delegated consent.</div>
|
||||
</div>
|
||||
<x-filament::button color="gray" size="sm" x-on:click="showCopyDelegated = false">Close</x-filament::button>
|
||||
</div>
|
||||
|
||||
@if ($copyDelegated === '')
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
Nothing to copy — no missing delegated permissions in the current feature filter.
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="mt-4 space-y-2"
|
||||
x-data="{
|
||||
text: @js($copyDelegated),
|
||||
copied: false,
|
||||
async copyPayload() {
|
||||
try {
|
||||
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
||||
await navigator.clipboard.writeText(this.text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.inset = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
this.copied = true;
|
||||
setTimeout(() => (this.copied = false), 1500);
|
||||
} catch (e) {
|
||||
this.copied = false;
|
||||
}
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Copied
|
||||
</span>
|
||||
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
|
||||
Copy
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||||
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Details">
|
||||
@if (! $tenant)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No tenant selected.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Search doesn’t affect copy actions. Feature filters do.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
|
||||
Reset
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
|
||||
<select wire:model.live="status" class="fi-input fi-select w-full">
|
||||
<option value="missing">Missing</option>
|
||||
<option value="present">Present</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
|
||||
<select wire:model.live="type" class="fi-input fi-select w-full">
|
||||
<option value="all">All</option>
|
||||
<option value="application">Application</option>
|
||||
<option value="delegated">Delegated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 sm:col-span-2">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
|
||||
<input
|
||||
type="search"
|
||||
wire:model.live.debounce.500ms="search"
|
||||
class="fi-input w-full"
|
||||
placeholder="Search permission key or description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if ($featureOptions !== [])
|
||||
<div class="space-y-1 sm:col-span-4">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
|
||||
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
|
||||
@foreach ($featureOptions as $feature)
|
||||
<option value="{{ $feature }}">{{ $feature }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($requiredTotal === 0)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-semibold text-gray-950 dark:text-white">No permissions configured</div>
|
||||
<div class="mt-1">
|
||||
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($permissions === [])
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
|
||||
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
|
||||
<div class="mt-1">
|
||||
Switch Status to “All” if you want to review the full matrix.
|
||||
</div>
|
||||
@else
|
||||
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
|
||||
<div class="mt-1">
|
||||
No permissions match the current filters.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$featuresToRender = $featureImpacts;
|
||||
|
||||
if ($selectedFeatures !== []) {
|
||||
$featuresToRender = collect($featureImpacts)
|
||||
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@endphp
|
||||
|
||||
@foreach ($featuresToRender as $impact)
|
||||
@php
|
||||
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
|
||||
$featureKey = is_string($featureKey) ? $featureKey : null;
|
||||
|
||||
if ($featureKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = collect($permissions)
|
||||
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($rows === []) {
|
||||
continue;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $featureKey }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Permission
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
|
||||
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
|
||||
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
|
||||
$description = is_array($row) ? ($row['description'] ?? null) : null;
|
||||
$description = is_string($description) ? $description : null;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
|
||||
@endphp
|
||||
|
||||
<tr
|
||||
class="align-top"
|
||||
data-permission-key="{{ $key }}"
|
||||
data-permission-type="{{ $type }}"
|
||||
data-permission-status="{{ $status }}"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $key }}
|
||||
</div>
|
||||
@if ($description)
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $description }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament::page>
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Tenant Required Permissions Page (Enterprise Remediation UX)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-05
|
||||
**Feature**: [specs/076-permissions-enterprise-ui/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items validated against [specs/076-permissions-enterprise-ui/spec.md](../spec.md).
|
||||
- Domain terms like “Microsoft Graph permissions” are treated as product-domain vocabulary, not implementation detail; the spec avoids describing external call mechanics.
|
||||
@ -0,0 +1,73 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RequiredPermissionsPageViewModel",
|
||||
"type": "object",
|
||||
"required": ["tenant", "overview", "permissions", "filters"],
|
||||
"properties": {
|
||||
"tenant": {
|
||||
"type": "object",
|
||||
"required": ["id", "external_id", "name"],
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"external_id": {"type": "string"},
|
||||
"name": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"type": "object",
|
||||
"required": ["overall", "counts", "feature_impacts"],
|
||||
"properties": {
|
||||
"overall": {"type": "string", "enum": ["ready", "needs_attention", "blocked", "running"]},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"required": ["missing_application", "missing_delegated", "present", "error"],
|
||||
"properties": {
|
||||
"missing_application": {"type": "integer", "minimum": 0},
|
||||
"missing_delegated": {"type": "integer", "minimum": 0},
|
||||
"present": {"type": "integer", "minimum": 0},
|
||||
"error": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"feature_impacts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["feature", "missing", "required_application", "required_delegated", "blocked"],
|
||||
"properties": {
|
||||
"feature": {"type": "string"},
|
||||
"missing": {"type": "integer", "minimum": 0},
|
||||
"required_application": {"type": "integer", "minimum": 0},
|
||||
"required_delegated": {"type": "integer", "minimum": 0},
|
||||
"blocked": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["key", "type", "features", "status"],
|
||||
"properties": {
|
||||
"key": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["application", "delegated"]},
|
||||
"description": {"type": ["string", "null"]},
|
||||
"features": {"type": "array", "items": {"type": "string"}},
|
||||
"status": {"type": "string", "enum": ["granted", "missing", "error"]},
|
||||
"details": {"type": ["object", "null"], "additionalProperties": true}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"required": ["status", "type", "features", "search"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["missing", "present", "all"]},
|
||||
"type": {"type": "string", "enum": ["application", "delegated", "all"]},
|
||||
"features": {"type": "array", "items": {"type": "string"}},
|
||||
"search": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
# Verification Report — Clustered Checks (Spec 076)
|
||||
|
||||
This feature extends the existing verification report (`operation_runs.context.verification_report`) with additional *clustered* checks derived from required-permission coverage.
|
||||
|
||||
## Source of truth
|
||||
- Schema: `app/Support/Verification/VerificationReportSchema.php` (v1.x)
|
||||
- Writer: `app/Support/Verification/VerificationReportWriter.php`
|
||||
|
||||
## Proposed check keys (stable)
|
||||
|
||||
The report should contain 5–7 checks. Suggested keys:
|
||||
|
||||
1) `provider.connection.check` (existing)
|
||||
2) `permissions.admin_consent`
|
||||
3) `permissions.directory_groups`
|
||||
4) `permissions.intune_configuration`
|
||||
5) `permissions.intune_apps`
|
||||
6) `permissions.intune_rbac_assignments`
|
||||
7) `permissions.scripts_remediations` (optional / skip when irrelevant)
|
||||
|
||||
## Check computation rules
|
||||
|
||||
Each check must produce the fields required by the schema:
|
||||
- `key`, `title`, `status` (`pass|fail|warn|skip|running`)
|
||||
- `severity` (`info|warning|critical|...`)
|
||||
- `blocking` (bool)
|
||||
- `reason_code`, `message`
|
||||
- `evidence[]` pointers
|
||||
- `next_steps[]` links
|
||||
|
||||
### Permission-derived checks
|
||||
- Input: permission comparison rows from `TenantPermissionService::compare(...persist: false, liveCheck: false)`.
|
||||
- Status rules:
|
||||
- `pass`: all mapped permissions are granted
|
||||
- `fail`: at least one mapped permission is missing and the cluster is marked blocking
|
||||
- `warn`: optional/non-blocking missing permissions (if introduced later)
|
||||
- `skip`: no mapped permissions apply (or feature is irrelevant)
|
||||
|
||||
### Evidence pointers
|
||||
Recommended evidence pointers:
|
||||
- `{ kind: 'missing_permission', value: '<permission_key>' }`
|
||||
- `{ kind: 'permission_type', value: 'application|delegated' }`
|
||||
- `{ kind: 'feature', value: '<feature_key>' }`
|
||||
|
||||
### Next steps
|
||||
For `fail`/blocking checks:
|
||||
- Include a CTA: `Open required permissions` → links to the tenant-scoped Required Permissions page.
|
||||
- Optionally include a pre-filtered URL by feature.
|
||||
|
||||
## Issues-first rendering
|
||||
UI should sort checks:
|
||||
1) blocking fails
|
||||
2) non-blocking fails
|
||||
3) warns
|
||||
4) running
|
||||
5) skips
|
||||
6) passes
|
||||
|
||||
The onboarding wizard verify step should render the same check set, not only `failure_summary`.
|
||||
92
specs/076-permissions-enterprise-ui/data-model.md
Normal file
92
specs/076-permissions-enterprise-ui/data-model.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Data Model — Spec 076 (Permissions Enterprise UI)
|
||||
|
||||
## Primary entities
|
||||
|
||||
### Tenant
|
||||
- Source: `app/Models/Tenant.php`
|
||||
- Used for scoping and tenancy routing (`/admin/t/{tenant}/...`).
|
||||
|
||||
### RequiredPermissionDefinition (config)
|
||||
- Source: `config/intune_permissions.php` (`permissions` array)
|
||||
- Shape:
|
||||
- `key: string` (e.g. `DeviceManagementConfiguration.Read.All`)
|
||||
- `type: 'application'|'delegated'` (current config is application-only, but model supports both)
|
||||
- `description: ?string`
|
||||
- `features: string[]` (feature tags used for grouping/impact)
|
||||
|
||||
### TenantPermission (DB)
|
||||
- Source: `app/Models/TenantPermission.php` (table: `tenant_permissions`)
|
||||
- Key fields (inferred from service usage):
|
||||
- `tenant_id: int`
|
||||
- `permission_key: string`
|
||||
- `status: 'granted'|'missing'|'error'`
|
||||
- `details: ?array`
|
||||
- `last_checked_at: ?datetime`
|
||||
|
||||
### PermissionComparisonResult (computed)
|
||||
- Source: `TenantPermissionService::compare(...)`
|
||||
- Shape:
|
||||
- `overall_status: 'granted'|'missing'|'error'` (service-level)
|
||||
- `permissions: PermissionRow[]`
|
||||
|
||||
### PermissionRow (computed)
|
||||
- Shape:
|
||||
- `key: string`
|
||||
- `type: 'application'|'delegated'`
|
||||
- `description: ?string`
|
||||
- `features: string[]`
|
||||
- `status: 'granted'|'missing'|'error'`
|
||||
- `details: ?array`
|
||||
|
||||
## View models
|
||||
|
||||
### RequiredPermissionsOverview
|
||||
- Inputs: `PermissionRow[]`
|
||||
- Derived fields:
|
||||
- `overall: VerificationReportOverall` where:
|
||||
- Blocked if any missing application
|
||||
- NeedsAttention if only delegated missing
|
||||
- Ready if none missing
|
||||
- counts:
|
||||
- `missing_application_count`
|
||||
- `missing_delegated_count`
|
||||
- `present_count`
|
||||
- `error_count`
|
||||
- `feature_impacts: FeatureImpact[]`
|
||||
|
||||
### FeatureImpact
|
||||
- Key: `feature: string`
|
||||
- Derived:
|
||||
- `missing_count`
|
||||
- `required_application_count`
|
||||
- `required_delegated_count`
|
||||
- `blocked: bool` (based on missing application for that feature)
|
||||
|
||||
### RequiredPermissionsFilterState
|
||||
- Livewire-backed state on the page:
|
||||
- `status: missing|present|all` (default: missing)
|
||||
- `type: application|delegated|all` (default: all)
|
||||
- `features: string[]` (default: [])
|
||||
- `search: string` (default: '')
|
||||
|
||||
### CopyPayload
|
||||
- Derived string payload:
|
||||
- Always `status = missing`
|
||||
- Always `type = application|delegated` (fixed by clicked button)
|
||||
- Respects only `features[]` filter
|
||||
- Ignores `search`
|
||||
- Newline separated `permission.key`
|
||||
|
||||
## Verification report model (clustered checks)
|
||||
|
||||
### VerificationReport (stored on OperationRun)
|
||||
- Source: `operation_runs.context['verification_report']`
|
||||
- Schema: `app/Support/Verification/VerificationReportSchema.php`
|
||||
|
||||
### VerificationCheck (cluster)
|
||||
- Key fields (schema-required):
|
||||
- `key`, `title`, `status`, `severity`, `blocking`, `reason_code`, `message`, `evidence[]`, `next_steps[]`
|
||||
|
||||
### Cluster mapping
|
||||
- Cluster definitions map check key → permission keys (or permission feature sets).
|
||||
- Permission-derived checks compute status from `PermissionRow[]` and supply next-step URL to the Required Permissions page.
|
||||
147
specs/076-permissions-enterprise-ui/plan.md
Normal file
147
specs/076-permissions-enterprise-ui/plan.md
Normal file
@ -0,0 +1,147 @@
|
||||
# Implementation Plan: 076-permissions-enterprise-ui
|
||||
|
||||
**Branch**: `076-permissions-enterprise-ui` | **Date**: 2026-02-05
|
||||
|
||||
**Spec**: specs/076-permissions-enterprise-ui/spec.md
|
||||
|
||||
**Input**: specs/076-permissions-enterprise-ui/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Implement the “Tenant Required Permissions (Enterprise Remediation UX)” plus the Verify-step clustering:
|
||||
|
||||
- Tenant-scoped Filament Page for required permissions with an overview section + details matrix.
|
||||
- Copy-to-clipboard for missing permissions split by type (application vs delegated), with clarified semantics.
|
||||
- Verification report updates to emit 5–7 clustered checks and onboarding Verify step rendering updates (issues-first), deep-linking to the Required Permissions page.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
|
||||
**Primary Dependencies**: Filament v5 + Livewire v4.0+
|
||||
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
|
||||
**Testing**: Pest v4
|
||||
|
||||
**Target Platform**: Filament admin panel with tenancy routing (`/admin/t/{tenant}/...`)
|
||||
|
||||
**Project Type**: Laravel monolith
|
||||
|
||||
**Performance Goals**: DB-only render; in-memory filtering on config-sized datasets (<~200 rows)
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- Required Permissions page is DB-only at render (no Graph/HTTP).
|
||||
- Tenant isolation / RBAC-UX:
|
||||
- non-member tenant access is 404 via existing middleware
|
||||
- member without `Capabilities::TENANT_VIEW` is 403
|
||||
- Badge semantics (BADGE-001): use centralized `BadgeDomain` mappings only.
|
||||
- Copy semantics: respects Feature filter only; ignores Search; always copies Missing only; Type fixed by clicked button.
|
||||
- Enterprise correctness: verification runs refresh Observed permissions inventory (Graph) and persist it; viewer surfaces remain DB-only.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Read/write separation: PASS (page is read-only; copy is client-side)
|
||||
- Graph contract path: PASS (no Graph calls on render)
|
||||
- RBAC-UX: PASS (404 for non-members; 403 for missing capability)
|
||||
- Badge semantics: PASS (explicit badge domains are fixed in spec)
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
- specs/076-permissions-enterprise-ui/spec.md
|
||||
- specs/076-permissions-enterprise-ui/plan.md (this file)
|
||||
- specs/076-permissions-enterprise-ui/research.md
|
||||
- specs/076-permissions-enterprise-ui/data-model.md
|
||||
- specs/076-permissions-enterprise-ui/contracts/*
|
||||
- specs/076-permissions-enterprise-ui/quickstart.md
|
||||
- specs/076-permissions-enterprise-ui/tasks.md (generated next via speckit)
|
||||
|
||||
### Code (planned)
|
||||
|
||||
- app/Filament/Pages/TenantRequiredPermissions.php (new)
|
||||
- resources/views/filament/pages/tenant-required-permissions.blade.php (new)
|
||||
- app/Jobs/ProviderConnectionHealthCheckJob.php (extend verification report)
|
||||
- app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (render check clusters)
|
||||
- resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php (render checks)
|
||||
- tests/Feature/* (Pest)
|
||||
|
||||
## Phase 0 — Outline & Research (COMPLETE)
|
||||
|
||||
Output: specs/076-permissions-enterprise-ui/research.md
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Dedicated tenant-scoped Filament Page for Required Permissions.
|
||||
- Use DB-only permission status (`tenant_permissions`) + `config/intune_permissions.php` for required definitions.
|
||||
- Implement copy actions via existing robust clipboard fallback pattern.
|
||||
- Compute clustered verification checks when writing the verification report (job/service), not in Blade.
|
||||
- Refresh Observed permission inventory during the verification run (Operation Run), not in any viewer surface.
|
||||
|
||||
## Phase 1 — Design & Contracts (COMPLETE)
|
||||
|
||||
Outputs:
|
||||
|
||||
- specs/076-permissions-enterprise-ui/data-model.md
|
||||
- specs/076-permissions-enterprise-ui/contracts/required-permissions.view-model.json
|
||||
- specs/076-permissions-enterprise-ui/contracts/verification-report.checks.md
|
||||
- specs/076-permissions-enterprise-ui/quickstart.md
|
||||
|
||||
Remaining required step in this phase:
|
||||
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh copilot`.
|
||||
|
||||
## Phase 2 — Implementation Planning (READY)
|
||||
|
||||
### 2.1 Tenant Required Permissions Page
|
||||
|
||||
- Route/tenancy: create tenant page at slug `required-permissions` (under `/admin/t/{tenant}/required-permissions`).
|
||||
- Authorization:
|
||||
- non-member 404 is enforced by existing tenancy middleware
|
||||
- add `canAccess()` check for `Capabilities::TENANT_VIEW` (403)
|
||||
- Data:
|
||||
- required definitions: `config/intune_permissions.php`
|
||||
- tenant status: `tenant_permissions` via `TenantPermissionService` with DB-only semantics
|
||||
- Overview:
|
||||
- overall status mapping: Blocked if any missing application; Needs attention if only delegated missing; Ready if none missing
|
||||
- impacted features summary (from permission → features tags), with clickable cards that apply a Feature filter
|
||||
- primary next step: Admin consent guide link (prefer existing tenant-specific Admin Consent URL; fall back to external guide)
|
||||
- Details matrix:
|
||||
- missing-first sorting
|
||||
- filters: Feature, Type, Status
|
||||
- search across key/description
|
||||
- Badges:
|
||||
- per-row uses `BadgeDomain::TenantPermissionStatus`
|
||||
- overview uses `BadgeDomain::VerificationReportOverall`
|
||||
- Copy actions:
|
||||
- copy missing application
|
||||
- copy missing delegated
|
||||
- selection respects Feature filter only; ignores Search; always Missing-only
|
||||
|
||||
### 2.2 Verification Check Clustering
|
||||
|
||||
- Extend verification report writing to include 5–7 clustered checks derived from required permissions status.
|
||||
- Follow specs/076-permissions-enterprise-ui/contracts/verification-report.checks.md for keys + status rules.
|
||||
- Ensure permission clusters only assert “missing” when Observed inventory refresh succeeded during the run; otherwise degrade to warnings with retry guidance.
|
||||
|
||||
### 2.3 Verify Step UI
|
||||
|
||||
- Update onboarding Verify step to render check clusters from `verification_report` (via `VerificationReportViewer`).
|
||||
- Issues-first ordering: failures, then warnings, then passes.
|
||||
- Provide an explicit “Open Required Permissions” next-step link.
|
||||
|
||||
### 2.4 Tests (Pest)
|
||||
|
||||
- Access:
|
||||
- member without `tenant.view` gets 403
|
||||
- non-member tenant access remains 404
|
||||
- Copy semantics:
|
||||
- Feature filter affects payload; Search does not
|
||||
- type-specific copy returns only missing of that type
|
||||
- Verification report:
|
||||
- cluster keys present
|
||||
- cluster status mapping matches missing application vs delegated rules
|
||||
32
specs/076-permissions-enterprise-ui/quickstart.md
Normal file
32
specs/076-permissions-enterprise-ui/quickstart.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Quickstart — Spec 076 (Permissions Enterprise UI)
|
||||
|
||||
## Local run (Sail)
|
||||
- Start: `vendor/bin/sail up -d`
|
||||
- App: `vendor/bin/sail open`
|
||||
|
||||
## Where to click
|
||||
- In the Admin panel (`/admin`), choose a workspace + tenant.
|
||||
- Open the tenant-scoped “Required permissions” page (`/admin/t/{tenant}/...`).
|
||||
- In the Managed Tenant Onboarding Wizard → “Verify access”, start verification and review the clustered checks.
|
||||
|
||||
## Expected UX
|
||||
- Overview above the fold:
|
||||
- Overall status badge (Ready / Needs attention / Blocked)
|
||||
- Impact summary by feature
|
||||
- Copy missing application / delegated actions
|
||||
- Details matrix:
|
||||
- Missing-first default
|
||||
- Status/type/feature filters + substring search
|
||||
|
||||
## Tests (minimal, targeted)
|
||||
Run only tests relevant to Spec 076 changes:
|
||||
- `vendor/bin/sail artisan test --compact --filter=RequiredPermissions`
|
||||
- If you add a dedicated test file, run it directly:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissionsTest.php`
|
||||
|
||||
## Formatting
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
|
||||
## Deploy notes
|
||||
- No new assets expected (Blade/Livewire only).
|
||||
- If any Filament assets are registered later, ensure deployment runs `php artisan filament:assets`.
|
||||
98
specs/076-permissions-enterprise-ui/research.md
Normal file
98
specs/076-permissions-enterprise-ui/research.md
Normal file
@ -0,0 +1,98 @@
|
||||
# Research — Spec 076 (Permissions Enterprise UI)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Build a dedicated tenant-scoped Filament Page
|
||||
- Decision: Implement a new Filament Page under the tenant panel route (`/admin/t/{tenant}/...`) for the “Required permissions” enterprise remediation UX.
|
||||
- Rationale:
|
||||
- The existing `TenantResource` infolist entry is a raw list; Spec 076 requires a two-layer remediation layout (overview + matrix) and feature grouping.
|
||||
- A dedicated Page can provide operator-first UX without bloating the tenant detail resource.
|
||||
- Alternatives considered:
|
||||
- Extending the existing `TenantResource` view: rejected because it couples a complex remediation UI to a general-purpose resource view and makes verification deep-linking/clustering harder.
|
||||
|
||||
### 2) Use stored + config-based data only at render time (DB-only render)
|
||||
- Decision: The page loads required permissions from `config('intune_permissions.permissions')` and granted/missing statuses from the `tenant_permissions` table via `TenantPermissionService::compare($tenant, persist: false, liveCheck: false, useConfiguredStub: false)`.
|
||||
- Rationale:
|
||||
- Satisfies FR-076-008 (no external network calls during page view).
|
||||
- Reuses existing data normalization and status modeling.
|
||||
- Alternatives considered:
|
||||
- Calling Graph on page view (`liveCheck: true`): rejected (explicitly out of scope and violates DB-only render).
|
||||
|
||||
### 3) Authorization semantics: non-member 404, member missing capability 403
|
||||
- Decision:
|
||||
- Non-member tenant access remains deny-as-not-found (404) via the Admin panel middleware (`DenyNonMemberTenantAccess`).
|
||||
- Page access is capability-gated via `Page::canAccess()` using `Capabilities::TENANT_VIEW`.
|
||||
- Rationale:
|
||||
- Matches Constitution RBAC-UX-002 and RBAC-UX-003.
|
||||
- Ensures correct semantics for both initial request and Livewire requests.
|
||||
- Alternatives considered:
|
||||
- Enforcing capability only in `mount()` with custom `abort(...)`: rejected because `canAccess()` is the consistent Filament entry-point gate and keeps nav hiding in sync.
|
||||
|
||||
### 4) Badge semantics: use centralized domains only
|
||||
- Decision:
|
||||
- Per-permission badges: `BadgeDomain::TenantPermissionStatus`.
|
||||
- Overall status badge: `BadgeDomain::VerificationReportOverall` with values from `VerificationReportOverall`.
|
||||
- Rationale:
|
||||
- Constitution BADGE-001 requires centralized semantic mapping.
|
||||
|
||||
### 5) Filters/search implementation: server-side Livewire state, in-memory filtering
|
||||
- Decision: Represent filter/search state as Livewire properties on the Page and filter the already-loaded permission array in-memory.
|
||||
- Rationale:
|
||||
- Dataset size is small (config-defined permissions), so in-memory filtering is fast and stable.
|
||||
- Enables programmatic tests for filtering/copy payload generation without relying on browser JS.
|
||||
- Alternatives considered:
|
||||
- Filament `Tables` with query-backed filters: rejected as unnecessary complexity for a config-driven list.
|
||||
- Pure client-side Alpine filtering: rejected due to weaker automated testability.
|
||||
|
||||
### 6) Copy-to-clipboard: “copy payload modal” + robust clipboard fallback
|
||||
- Decision: Implement copy actions that open a modal (or inline panel) containing the exact newline-separated payload; a “Copy” button uses the existing robust Alpine clipboard fallback pattern.
|
||||
- Rationale:
|
||||
- Clipboard APIs are browser-only; Livewire actions cannot write directly to clipboard.
|
||||
- Reuses proven fallback approach in `resources/views/filament/partials/json-viewer.blade.php`.
|
||||
- Makes copy output auditable/visible before copying (enterprise-friendly).
|
||||
- Alternatives considered:
|
||||
- Attempting server-side copy: not possible.
|
||||
|
||||
### 7) Verify-step clustering: emit clustered checks in `verification_report`
|
||||
- Decision:
|
||||
- Extend the queued verification job (`ProviderConnectionHealthCheckJob`) to write a verification report that includes the existing connection check plus 5–6 permission cluster checks.
|
||||
- Update the onboarding wizard Verify step to render `OperationRun.context.verification_report` (via `VerificationReportViewer`) and show checks issues-first.
|
||||
- Rationale:
|
||||
- The project already has a report schema (`VerificationReportSchema`) and writer (`VerificationReportWriter`).
|
||||
- Clustering in the report keeps the experience consistent across the wizard and operation-run detail views.
|
||||
- Alternatives considered:
|
||||
- Cluster in Blade only: rejected because it does not affect summary/overall and can drift between views.
|
||||
|
||||
### 8) Enterprise correctness: refresh Observed permissions during the verification run
|
||||
- Decision:
|
||||
- The queued verification run (Operation Run) attempts a live Graph refresh for Observed permissions and persists it to `tenant_permissions`.
|
||||
- Viewer surfaces (Required Permissions page, onboarding Verify step, operation run viewer) remain DB-only at render time.
|
||||
- If the refresh fails (429/network), permission clusters degrade to warnings with retry guidance and MUST NOT assert “missing permissions” based on stale/empty inventory.
|
||||
- Rationale:
|
||||
- Prevents false “missing” findings when the stored inventory is empty/stale.
|
||||
- Keeps all external calls in the queued run, maintaining the DB-only render rule.
|
||||
|
||||
## Open questions resolved (NEEDS CLARIFICATION → decision)
|
||||
|
||||
### “Enabled features” for impact summary
|
||||
- Decision: In Spec 076 scope, treat “enabled features” as the feature tags present in `config('intune_permissions.permissions')`.
|
||||
- Rationale: There is no current per-tenant feature-enable registry in the codebase; feature tags already exist and are deterministic.
|
||||
- Future upgrade path: If/when tenant-specific enablement exists, compute relevance by intersecting enabled features with permission feature tags.
|
||||
|
||||
## Check cluster proposal (stable keys)
|
||||
|
||||
Target: 5–7 checks; issues-first.
|
||||
|
||||
- `provider.connection.check` (existing)
|
||||
- `permissions.admin_consent` (overall admin consent / application permissions missing)
|
||||
- `permissions.directory_groups`
|
||||
- `permissions.intune_configuration`
|
||||
- `permissions.intune_apps`
|
||||
- `permissions.intune_rbac_assignments`
|
||||
- `permissions.scripts_remediations` (optional / skip when irrelevant)
|
||||
|
||||
Each permission-derived check:
|
||||
- Pass: no missing permissions in its mapped set
|
||||
- Fail/Blocked: any missing required permission in its set
|
||||
- Skip: cluster mapped permissions set is empty (or feature not relevant)
|
||||
- Next step: “Open required permissions” deep link to the new page (optionally pre-filtered by Feature).
|
||||
242
specs/076-permissions-enterprise-ui/spec.md
Normal file
242
specs/076-permissions-enterprise-ui/spec.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Feature Specification: Tenant Required Permissions Page (Enterprise Remediation UX)
|
||||
|
||||
**Feature Branch**: `076-permissions-enterprise-ui`
|
||||
**Created**: 2026-02-05
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 076 — Tenant Required Permissions Page (Enterprise Remediation UX); upgrade tenant required permissions list into an operator-friendly remediation page with summary, prioritization, feature grouping, guidance, copy-to-clipboard, filters/search, strict tenant-scoped RBAC semantics, badge mapping centralization, DB-only render; plus an enterprise check clustering for verification step (5–7 checks)."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-05
|
||||
|
||||
- Q: What capability is required to view the Required permissions page? → A: Require `tenant.view` (Capabilities::TENANT_VIEW). Non-members remain deny-as-not-found.
|
||||
- Q: What is the overall status mapping for missing permissions? → A: Blocked if any missing application permissions; Needs attention if only delegated permissions are missing; Ready if nothing is missing.
|
||||
- Q: Should copy-to-clipboard respect filters/search? → A: Copy respects the current Feature filter only; it ignores Search; and it always enforces Status=Missing and Type fixed by the button (app vs delegated).
|
||||
- Q: Does Spec 076 include Verify-step clustering UI changes? → A: Yes. Spec 076 implements the Required Permissions page and updates the Verify-step UI to show clustered checks (5–7).
|
||||
- Q: Which centralized badge mappings should be used? → A: Per-permission status uses `BadgeDomain::TenantPermissionStatus`; Overview overall status uses `BadgeDomain::VerificationReportOverall`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Operator sees impact at a glance (Priority: P1)
|
||||
|
||||
As an Operator, I can immediately understand whether the tenant is blocked, which enabled features are impacted, and how many required permissions are missing.
|
||||
|
||||
**Why this priority**: This reduces time-to-diagnosis and prevents “permission soup” confusion during onboarding and incident response.
|
||||
|
||||
**Independent Test**: Can be fully tested by loading the page for a tenant with mixed coverage and verifying that the Overview summarizes blocked features and shows “missing-first” by default.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has at least one enabled feature with missing required permissions, **When** I open “Required permissions”, **Then** I see a “Blocked/Needs attention” status and a summary that names impacted features.
|
||||
2. **Given** a tenant has missing permissions, **When** I first load the page, **Then** the default view shows only missing items and groups them by feature.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Global Admin can act quickly (Priority: P1)
|
||||
|
||||
As a Global Administrator (or delegated privileged operator), I can copy the missing application permissions and missing delegated permissions separately, and I clearly understand that admin consent is required.
|
||||
|
||||
**Why this priority**: This turns diagnosis into a one-step remediation action and reduces mistakes (mixing delegated/app permissions).
|
||||
|
||||
**Independent Test**: Can be fully tested by verifying that copy actions produce newline-separated permission names for each type and that the guidance block explains “who/how”.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** there are missing application permissions, **When** I click “Copy missing application permissions”, **Then** my clipboard receives a newline-separated list of the missing application permission names.
|
||||
2. **Given** there are missing delegated permissions, **When** I click “Copy missing delegated permissions”, **Then** my clipboard receives a newline-separated list of the missing delegated permission names.
|
||||
3. **Given** any missing permissions exist, **When** I read the guidance section, **Then** it states that a Global Administrator must grant admin consent.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Deep dive and triage remains possible (Priority: P2)
|
||||
|
||||
As an Operator, I can filter and search all required permissions to answer “what exactly is missing, for which feature, and what type is it?”
|
||||
|
||||
**Why this priority**: Enables troubleshooting and audit readiness without leaving the product.
|
||||
|
||||
**Independent Test**: Can be fully tested by applying filters (Status/Type/Feature) and search terms and verifying table results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has both present and missing permissions, **When** I change Status to “All”, **Then** I can see both missing and present permissions.
|
||||
2. **Given** the list contains many permissions, **When** I search by permission key or description, **Then** only matching permissions are shown.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Unauthorized users see nothing (Priority: P1)
|
||||
|
||||
As a non-member of a tenant, I cannot discover the existence of its required permissions page or its contents.
|
||||
|
||||
**Why this priority**: Prevents cross-tenant information leakage in enterprise environments.
|
||||
|
||||
**Independent Test**: Can be fully tested by requesting the page as (a) a tenant member and (b) a non-member and verifying deny-as-not-found behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am not entitled to the tenant scope, **When** I request the required permissions page, **Then** I receive a not found outcome.
|
||||
2. **Given** I am entitled to the tenant scope, **When** I request the page, **Then** I can view the summary and permission matrix.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a tenant has zero required permissions (e.g., no enabled features)? Overview MUST show “Ready” and the details list MUST be empty with a clear “nothing required” message.
|
||||
- What happens when there are no missing permissions? Default “Missing” filter yields an empty state, and Overview MUST show “Ready”.
|
||||
- How does the system handle a permission that belongs to multiple features? It MUST appear under each relevant feature grouping and contribute to impact counts without double-counting within a feature.
|
||||
- What happens when copy actions are triggered but there are zero missing permissions of that type? Copy action MUST either be disabled or copy an empty string with an explicit, user-visible message.
|
||||
- How does the system handle extremely long permission lists? Filtering and search MUST remain usable and stable.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-076-001 — Two-layer layout (Overview + Details)**: The page MUST present two layers:
|
||||
- **Layer A (Overview, above the fold)**: status banner, impact summary, counts, next-step actions.
|
||||
- **Layer B (Full matrix)**: full list/matrix with filters + search and grouping options.
|
||||
|
||||
- **FR-076-002 — Overview content and defaults**: The Overview MUST include:
|
||||
1) A status indicator derived from tenant permission coverage (“Ready” / “Needs attention” / “Blocked”).
|
||||
2) An impact summary that lists which enabled features are blocked or at risk.
|
||||
3) Counts for missing application permissions, missing delegated permissions, and present permissions.
|
||||
4) A primary next step that points to an admin consent guide (prefer the existing tenant-specific Admin Consent URL when available; otherwise fall back to an external guide).
|
||||
5) Secondary actions for copying missing permissions (application vs delegated).
|
||||
6) A visible “Re-run verification” entry point back to the verification experience.
|
||||
|
||||
- **FR-076-002a — Overall status mapping (explicit)**: The overall status badge MUST be computed as:
|
||||
- **Blocked**: at least one required **application** permission is missing.
|
||||
- **Needs attention**: no missing application permissions, but at least one required **delegated** permission is missing.
|
||||
- **Ready**: no missing required permissions.
|
||||
|
||||
- **FR-076-003 — Missing-first experience**: The default view MUST show only missing permissions. Users MUST be able to switch to “Present” or “All”.
|
||||
|
||||
- **FR-076-004 — Feature-based grouping and impact model**: Each permission is tagged with one or more features. The UI MUST aggregate and present per-feature impact, including:
|
||||
- missing count per feature
|
||||
- required application count per feature
|
||||
- required delegated count per feature
|
||||
Feature group cards (or equivalent) MUST be clickable to apply a feature filter.
|
||||
|
||||
- **FR-076-005 — Full matrix filters and search**: The full matrix MUST support:
|
||||
- Status filter: Missing / Present / All
|
||||
- Type filter: Application / Delegated / All
|
||||
- Feature filter: multi-select across known features
|
||||
- Search: substring match by permission key and description
|
||||
|
||||
- **FR-076-006 — Copy-to-clipboard formats**: The UI MUST provide:
|
||||
- “Copy missing application permissions”
|
||||
- “Copy missing delegated permissions”
|
||||
Output MUST be newline-separated permission names. An optional “Advanced” action MAY provide a structured (non-secret) export; it MUST not include secrets, identifiers that increase tenant leakage risk, or any credential material.
|
||||
|
||||
- **FR-076-006a — Copy semantics with filters**: Copy outputs MUST:
|
||||
- Always include only **Missing** permissions.
|
||||
- Always include only the permission **Type** corresponding to the clicked button (Application vs Delegated).
|
||||
- Respect the **Feature** filter if one is applied.
|
||||
- Ignore any free-text **Search** term.
|
||||
|
||||
- **FR-076-007 — Operator guidance block**: The page MUST include a static guidance block that answers:
|
||||
- “Who can fix this?” (Global Administrator / Privileged Role Administrator)
|
||||
- “How long does it take?” (5–10 minutes, optional)
|
||||
- “After granting consent” → a clear “Re-run verification” action
|
||||
|
||||
- **FR-076-008 — Data source and isolation**: Viewing the page MUST use already-available, stored tenant permission requirement data (no external network calls during page view) and MUST be tenant-scoped.
|
||||
|
||||
- **FR-076-009 — RBAC semantics (deny-as-not-found)**: Authorization MUST enforce tenant isolation and avoid leakage:
|
||||
- Viewing the page MUST require the `tenant.view` capability.
|
||||
- Non-member / not entitled to the tenant scope MUST receive a not found outcome.
|
||||
- Member without `tenant.view` MUST receive a forbidden outcome.
|
||||
- No global search or cross-tenant navigation entry points may reveal inaccessible tenants or permission contents.
|
||||
|
||||
- **FR-076-010 — Badge semantics centralized (BADGE-001)**: Status-like badges used by this feature (e.g., permission status Missing/Present and overall coverage Ready/Needs attention/Blocked) MUST use a centralized semantic mapping/registry. No ad-hoc badge mapping is allowed inside feature UI logic.
|
||||
|
||||
- **FR-076-010a — Badge domains (explicit)**: The UI MUST use these centralized badge domains:
|
||||
- Per-permission status badge (Missing/Granted/Error): `BadgeDomain::TenantPermissionStatus`
|
||||
- Overview overall status badge (Ready/Needs attention/Blocked/Running): `BadgeDomain::VerificationReportOverall`
|
||||
|
||||
- **FR-076-011 — Verification check clustering (enterprise UX)**: The verification experience MUST support presenting a reduced set of “checks” (5–7) that cluster individual permissions into operator-friendly topics, while the Required Permissions page remains the deep-dive reference.
|
||||
- Each check MUST declare which permissions it covers.
|
||||
- Each check MUST compute a deterministic status based on missing vs present permissions relevant to enabled features.
|
||||
- Each blocked check MUST provide a next step that routes to the Required Permissions page.
|
||||
|
||||
- **FR-076-011a — Verify-step clustered presentation (in scope)**: The Verify-step UI MUST present clustered checks (target 5–7) instead of listing every permission individually, and MUST be issues-first:
|
||||
- Blocked checks are shown prominently by default.
|
||||
- Each blocked check includes a clear CTA to open the Required Permissions page.
|
||||
- The Verify-step retains a way to view passed/ready checks without overwhelming the default view.
|
||||
|
||||
- **FR-076-012 — Recommended default checks and mapping**: The product SHOULD use the following check clusters (names can be user-facing, keys are stable identifiers):
|
||||
- **C1 Provider authentication works**: can the provider authenticate.
|
||||
- **C2 Admin consent granted**: can required admin consent be verified.
|
||||
- **C3 Directory & group read access**: directory + groups prerequisites.
|
||||
- **C4 Intune configuration access**: configuration + service config permissions.
|
||||
- **C5 Intune apps access**: apps permissions.
|
||||
- **C6 Intune RBAC & assignments prerequisites**: RBAC permissions.
|
||||
- **C7 Scripts/remediations access** (optional): scripts permissions only when relevant features are enabled.
|
||||
|
||||
- **FR-076-013 — Cluster status rules (high level)**: For each cluster check:
|
||||
- **Pass** when all required permissions for enabled features in that cluster are present.
|
||||
- **Blocked** when any required permission is missing that prevents the related enabled features from functioning.
|
||||
- **Warning** when only optional/non-blocking permissions are missing.
|
||||
- **Skipped** when the cluster is irrelevant because the related feature set is not enabled.
|
||||
|
||||
- **FR-076-014 — Desired vs Observed + refresh semantics (enterprise correctness)**:
|
||||
- **Desired** permissions MUST come from configuration (`config/intune_permissions.php`).
|
||||
- **Observed** permissions MUST come from stored inventory (`tenant_permissions`).
|
||||
- The verification run (Operation Run) MUST attempt to refresh Observed inventory from Graph and persist it.
|
||||
- Viewer surfaces (onboarding Verify step, operation run viewer, Required Permissions page) MUST remain DB-only at render time.
|
||||
- A permission cluster MAY only claim “Missing required permission(s)” when Observed inventory is known-fresh (refreshed successfully during the run).
|
||||
- If the Observed refresh fails (e.g. throttling/network), permission clusters MUST degrade to **Warning** (non-blocking) with retry guidance; they MUST NOT assert “Missing” based on stale/empty inventory.
|
||||
- Evidence recorded in the verification report MUST remain sanitized and pointer-only (no raw Graph payloads or secrets).
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The system already maintains a tenant-scoped dataset of required permissions with attributes: permission name, permission type (application/delegated), status (missing/present), and associated features.
|
||||
- The system already knows which features are enabled for a tenant.
|
||||
- The Required Permissions page is reachable from the verification experience ("Open required permissions") and provides a "Re-run verification" path back.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing tenant required permissions dataset and coverage summary.
|
||||
- Existing verification experience entry points / deep links (including Spec 075 consumer links).
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Changing which permissions are required for a feature.
|
||||
- Granting admin consent inside the product (this feature only guides and prepares the operator/admin).
|
||||
- Any external network verification calls during page view.
|
||||
- Any external network verification calls during Required Permissions page view.
|
||||
|
||||
### Security & Evidence
|
||||
|
||||
- Verification report evidence MUST be safe-by-default:
|
||||
- pointer-only (IDs, permission keys, feature tags, HTTP status codes)
|
||||
- sanitized (no tokens/secrets)
|
||||
- no raw Graph responses or headers
|
||||
|
||||
### Validation Notes (what to verify)
|
||||
|
||||
- Overview: status + impacted features are visible without scrolling.
|
||||
- Overview status: Blocked/Needs attention/Ready matches missing application vs delegated logic.
|
||||
- Defaults: initial view is missing-only and grouped by feature.
|
||||
- Copy: application and delegated missing lists copy separately and match the current filtered tenant state.
|
||||
- Copy + filters: feature-filtered copy produces a feature-scoped missing list; search term does not affect copy.
|
||||
- Filters/search: Status, Type, Feature multi-select, and search all narrow results predictably.
|
||||
- RBAC: non-members receive a not found outcome; tenant-scoped users can view.
|
||||
- Badge semantics: all status badges use a centralized mapping.
|
||||
- Verification step does not produce false “missing permission” findings when inventory refresh fails; it warns and suggests retry.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant**: A customer environment, with membership/entitlement boundaries.
|
||||
- **Feature**: A product capability (e.g., backup, restore, drift, policy sync) that depends on permissions.
|
||||
- **Required Permission**: A named permission requirement with attributes: name, type (application/delegated), status (missing/present), and features[].
|
||||
- **Permission Coverage Summary**: Precomputed or derivable summary that supports overall status (“Ready/Needs attention/Blocked”) plus counts.
|
||||
- **Verification Check Cluster**: A named check that groups permissions and reports a status + next-step guidance.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- SC-076-001..003 are product/usability metrics and are not fully enforceable in automated tests. This feature uses proxy assertions (e.g., missing-only default, copy semantics, RBAC negative tests, and ≤ 7 clustered checks) to guard the intended experience.
|
||||
- **SC-076-001**: In usability testing, operators can identify which features are blocked and why in under 15 seconds on first page load.
|
||||
- **SC-076-002**: Global admins can copy the missing permission list (application or delegated) with one explicit action, with a task completion rate of at least 95%.
|
||||
- **SC-076-003**: The default “Missing” view reduces initial on-screen items compared to “All”, and users can reliably narrow results using Status/Type/Feature filters and search with a first-try success rate of at least 90%.
|
||||
- **SC-076-004**: Unauthorized users (non-members) cannot infer tenant existence or permission requirements via the page or global search entry points (validated by negative access tests).
|
||||
- **SC-076-005**: Verification step presents no more than 7 permission checks for a tenant, while still reflecting all underlying required permissions.
|
||||
220
specs/076-permissions-enterprise-ui/tasks.md
Normal file
220
specs/076-permissions-enterprise-ui/tasks.md
Normal file
@ -0,0 +1,220 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: 076-permissions-enterprise-ui
|
||||
|
||||
**Input**: Design documents from `specs/076-permissions-enterprise-ui/`
|
||||
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
|
||||
|
||||
**RBAC (required)**:
|
||||
- Non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||
- Member but missing capability → 403
|
||||
- Capabilities MUST come from `App\Support\Auth\Capabilities`
|
||||
|
||||
**Badges (required)**:
|
||||
- Per-permission: `BadgeDomain::TenantPermissionStatus`
|
||||
- Overview overall: `BadgeDomain::VerificationReportOverall`
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Ensure the repo is ready for implementation and tests.
|
||||
|
||||
- [x] T001 Validate local dev quickstart in specs/076-permissions-enterprise-ui/quickstart.md
|
||||
- [x] T002 Confirm required permission definitions and feature tags exist in config/intune_permissions.php
|
||||
- [x] T003 [P] Locate and document the clipboard fallback partial to reuse in resources/views/filament/partials/json-viewer.blade.php
|
||||
- [x] T004 [P] Locate the verification report viewer/rendering surfaces in app/Filament/Support/VerificationReportViewer.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared building blocks used by all user stories.
|
||||
|
||||
- [x] T005 Create view-model builder skeleton in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T006 [P] Add unit tests for overall status mapping in tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
|
||||
- [x] T007 [P] Add unit tests for copy payload semantics in tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
|
||||
- [x] T008 Add a small DTO/array-shape contract for permission rows in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T009 [P] Add unit tests for per-feature impact aggregation in tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
|
||||
- [x] T010 Add a helper for Required Permissions deep links in app/Support/Links/RequiredPermissionsLinks.php
|
||||
|
||||
**Checkpoint**: Foundation ready (builder + core mapping tests).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Operator sees impact at a glance (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A tenant-scoped Required Permissions page that clearly shows overall status, impacted features, and missing-first by default.
|
||||
|
||||
**Independent Test**: Visit `/admin/t/{tenant}/required-permissions` for a tenant with mixed coverage; verify overview status + impacted features + missing-first list.
|
||||
|
||||
- [x] T011 [US1] Create tenant Filament page class in app/Filament/Pages/TenantRequiredPermissions.php
|
||||
- [x] T012 [US1] Create Blade view in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T013 [US1] Implement `canAccess()` (403 for members without capability) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||
- [x] T014 [US1] Wire builder into page mount/render using app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T015 [US1] Implement overall Ready/Needs attention/Blocked mapping using BadgeDomain::VerificationReportOverall in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T016 [US1] Render impacted-features summary cards (from permission feature tags) in resources/views/filament/pages/tenant-required-permissions.blade.php; cards are clickable to apply a Feature filter
|
||||
- [x] T017 [US1] Render missing-first, missing-only default list in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T018 [US1] Render per-permission rows with centralized badge semantics (BadgeDomain::TenantPermissionStatus) in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T019 [P] [US1] Add feature test for page renders overview, missing-first, and feature cards include a click-to-filter wiring in tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php
|
||||
|
||||
### Verify-step clustering (in-scope per FR-076-011/011a)
|
||||
|
||||
- [x] T020 [US1] Define clustered check keys + grouping logic in app/Support/Verification/TenantPermissionCheckClusters.php
|
||||
- [x] T021 [US1] Extend verification report writing to include clustered checks in app/Jobs/ProviderConnectionHealthCheckJob.php
|
||||
- [x] T022 [US1] Ensure clustered checks include next-step URL to Required Permissions (use app/Support/Links/RequiredPermissionsLinks.php)
|
||||
- [x] T023 [US1] Update onboarding wizard verify step to pass `verification_report` to view in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T024 [US1] Render clustered checks issues-first in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
- [x] T025 [P] [US1] Add feature test that renders clustered checks in onboarding verify report in tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
|
||||
- [x] T026 [P] [US1] Add unit tests for cluster status rules in tests/Unit/TenantPermissionCheckClustersTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Global Admin can act quickly (Priority: P1)
|
||||
|
||||
**Goal**: Copy missing application vs delegated permissions separately, with clear guidance about admin consent.
|
||||
|
||||
**Independent Test**: From the Required Permissions page, click each copy action and verify output is newline-separated and respects Feature filter only.
|
||||
|
||||
- [x] T027 [US2] Add guidance block (“Who can fix this?” / “After granting consent”), including a primary next step link to an admin consent guide (prefer tenant Admin Consent URL; fall back to external guide) in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T028 [US2] Add “Re-run verification” entry point in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T029 [US2] Add “Copy missing application permissions” button + modal in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T030 [US2] Add “Copy missing delegated permissions” button + modal in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T031 [US2] Reuse clipboard fallback logic from resources/views/filament/partials/json-viewer.blade.php in the new copy modal
|
||||
- [x] T032 [US2] Implement empty-copy UX (disabled action or explicit message) in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T033 [P] [US2] Add unit tests for copy respects Feature filter but ignores Search in tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
|
||||
- [x] T034 [P] [US2] Add feature test for presence of copy actions + guidance (including admin consent guide link) in tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Unauthorized users see nothing (Priority: P1)
|
||||
|
||||
**Goal**: Enforce deny-as-not-found for non-members and forbidden for members lacking `tenant.view`.
|
||||
|
||||
**Independent Test**: Request the page as a non-member (404), then as a member without capability (403).
|
||||
|
||||
- [x] T035 [US4] Ensure page does not register navigation by default and is not exposed via tenant-agnostic surfaces (e.g., global search / non-tenant nav) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||
- [x] T036 [P] [US4] Add feature test: non-member tenant access is 404 in tests/Feature/RequiredPermissions/RequiredPermissionsRbacTest.php
|
||||
- [x] T037 [P] [US4] Add feature test: member without tenant.view gets 403 in tests/Feature/RequiredPermissions/RequiredPermissionsRbacTest.php
|
||||
- [x] T038 [US4] Ensure capability checks reference registry constants (no raw strings) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||
- [x] T039 [US4] Ensure any deep links used by verification report do not leak cross-tenant data in app/Support/Links/RequiredPermissionsLinks.php
|
||||
- [x] T040 [P] [US4] Add regression test for link generation staying tenant-scoped in tests/Unit/RequiredPermissionsLinksTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 3 — Deep dive and triage remains possible (Priority: P2)
|
||||
|
||||
**Goal**: Filter/search the full matrix by Status/Type/Feature and search by permission key/description.
|
||||
|
||||
**Independent Test**: Apply filters and search; verify results update predictably and missing-first remains stable.
|
||||
|
||||
- [x] T041 [US3] Add Status filter (Missing/Present/All) state handling in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T042 [US3] Add Type filter (Application/Delegated/All) state handling in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T043 [US3] Add Feature multi-select filter support in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- [x] T044 [US3] Add substring search (by permission key/description) applied at render time (not affecting copy) in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T045 [US3] Add UI controls for filters/search in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T046 [P] [US3] Add unit tests for filter/search behavior in tests/Unit/TenantRequiredPermissionsFilteringTest.php
|
||||
- [x] T047 [P] [US3] Add feature test for filters narrowing results in tests/Feature/RequiredPermissions/RequiredPermissionsFiltersTest.php
|
||||
- [x] T048 [US3] Ensure copy payload ignores Search but respects Feature filter (assert in builder) in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T049 Run Pint formatting for touched files via vendor/bin/sail bin pint (see specs/076-permissions-enterprise-ui/quickstart.md)
|
||||
- [x] T050 Run targeted Pest tests via vendor/bin/sail artisan test --compact (see specs/076-permissions-enterprise-ui/quickstart.md)
|
||||
- [x] T051 [P] Ensure table empty states are meaningful (zero required / zero missing) in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||
- [x] T052 [P] Ensure the Verify-step check list does not exceed 7 items and remains issues-first in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
- [x] T053 [P] Add regression feature test: Required Permissions page render remains DB-only (no Graph client calls) in tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Enterprise Correctness — Observed Refresh in Verification Run
|
||||
|
||||
**Goal**: Prevent false “missing permissions” findings by refreshing Observed permissions inventory during the queued verification run (Operation Run), while keeping all viewer surfaces DB-only.
|
||||
|
||||
- [x] T054 Update live-check failure semantics in app/Services/Intune/TenantPermissionService.php (do not overwrite stored inventory; return live-check metadata)
|
||||
- [x] T055 Refresh observed permissions in app/Jobs/ProviderConnectionHealthCheckJob.php during successful provider checks (`liveCheck=true`, `persist=true`) and pass inventory freshness context into clustered checks
|
||||
- [x] T055a Use ProviderConnection graph options for permission refresh (avoid falling back to Tenant/global Graph config)
|
||||
- [x] T056 Degrade permission clusters to warnings when inventory refresh fails in app/Support/Verification/TenantPermissionCheckClusters.php
|
||||
- [x] T057 Tighten verification report evidence safety via allowlisting in app/Support/Verification/VerificationReportSanitizer.php
|
||||
- [x] T058 Add/adjust Pest tests covering: permission refresh invoked on healthy run, throttling/network refresh failure becomes warning (not missing), and no Graph calls are introduced into viewer renders
|
||||
- [x] T059 Treat successful-but-unmappable Graph permission inventory as non-fresh (warn) and add regression coverage (reason_code: permission_mapping_failed)
|
||||
- [x] T060 Degrade to warnings when live refresh returns empty inventory; surface app_id + observed count in verification report evidence
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Onboarding Wizard — Inline “Edit selected connection” (Option 1)
|
||||
|
||||
**Goal**: Edit the selected Provider Connection inline inside the onboarding wizard (SlideOver/Modal), without tenant-context navigation, while enforcing capability-first RBAC and requiring an explicit verification re-run after edits.
|
||||
|
||||
- [x] T061 Replace tenant-scoped edit link with an inline SlideOver edit action in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T062 Enforce RBAC: action disabled without capability, server-side 403 for missing capability, 404 for non-member/other-tenant scope
|
||||
- [x] T063 After save: invalidate verification/bootstrap state and set a “connection updated” flag so Verify step shows “Re-run verification” guidance
|
||||
- [x] T064 Add audit event `provider_connection.updated` with redacted metadata (no secrets)
|
||||
- [x] T065 Add Pest feature tests covering RBAC, wizard continuity, no tenant-context dependency/links, secret safety, and audit entry
|
||||
- [x] T066 Run Pint + targeted Pest tests for the new behavior
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Onboarding Wizard — Verify “Technical details” SlideOver
|
||||
|
||||
**Goal**: In the Verify step, provide a "Technical details" SlideOver with compact Operation Run summary and a "Refresh results" action, without showing an empty "Report unavailable" card in the SlideOver.
|
||||
|
||||
- [x] T067 Add Verify-step "Technical details" SlideOver showing run summary (run id/status/outcome, started/updated/completed, operation type + Entra tenant scope) and optional "Open full page" link
|
||||
- [x] T068 Add/adjust Pest feature test to ensure the Verify step renders the "Technical details" affordance when a verification run exists
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story completion order
|
||||
|
||||
- Setup → Foundational → US1 → (US2, US4 in parallel) → US3 → Polish
|
||||
|
||||
### Dependency graph
|
||||
|
||||
- US1 depends on Foundational (view-model builder + mappings)
|
||||
- US2 depends on US1 (copy actions live on the page)
|
||||
- US4 depends on US1 (route exists to assert 404/403)
|
||||
- US3 depends on US1 (matrix exists to filter)
|
||||
|
||||
## Parallel execution examples
|
||||
|
||||
### US1
|
||||
|
||||
- In parallel:
|
||||
- T011 (Page class) and T012 (Blade view)
|
||||
- T019 (feature test file scaffolding) can start once route is known
|
||||
|
||||
### US2
|
||||
|
||||
- In parallel:
|
||||
- T029/T030 (two copy buttons/modals) can be developed independently
|
||||
- T033 unit tests can be written while UI is built
|
||||
|
||||
### US4
|
||||
|
||||
- In parallel:
|
||||
- T036/T037 RBAC tests can be authored alongside US1 once page route exists
|
||||
|
||||
### US3
|
||||
|
||||
- In parallel:
|
||||
- T041–T043 builder filter support can be built while T045 UI controls are built
|
||||
- T046 unit tests can be written alongside implementation
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope (recommended)
|
||||
|
||||
- Complete Phase 1 + Phase 2 + Phase 3 (US1) first.
|
||||
- Validate independently via tests and by loading the tenant page.
|
||||
|
||||
### Incremental delivery
|
||||
|
||||
- Add copy + guidance (US2), then RBAC regression coverage (US4), then filters/search (US3).
|
||||
282
tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php
Normal file
282
tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php
Normal file
@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 403 when updating a selected connection without manage capability', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Acme connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'old-client-id',
|
||||
'client_secret' => 'top-secret-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||
'display_name' => 'Updated name',
|
||||
'client_id' => 'new-client-id',
|
||||
])
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('returns 404 when a non-member attempts inline connection update', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Acme connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'old-client-id',
|
||||
'client_secret' => 'top-secret-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
it('updates connection inline, invalidates verification state, and writes audit metadata without secrets', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Acme connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$secret = 'top-secret-client-secret';
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'old-client-id',
|
||||
'client_secret' => $secret,
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
'bootstrap_operation_runs' => [123, 456],
|
||||
'bootstrap_operation_types' => ['inventory.sync'],
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||
'display_name' => 'Updated name',
|
||||
'client_id' => 'new-client-id',
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$connection->refresh();
|
||||
|
||||
expect($connection->display_name)->toBe('Updated name');
|
||||
|
||||
$credential = $connection->credential;
|
||||
expect($credential)->not->toBeNull();
|
||||
expect($credential?->payload['client_id'] ?? null)->toBe('new-client-id');
|
||||
expect($credential?->payload['client_secret'] ?? null)->toBe($secret);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
expect($session->state['verification_operation_run_id'] ?? null)->toBeNull();
|
||||
expect($session->state['bootstrap_operation_runs'] ?? null)->toBeNull();
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBeNull();
|
||||
expect($session->state['connection_recently_updated'] ?? null)->toBeTrue();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.updated')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
|
||||
$encodedMetadata = json_encode($audit?->metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
expect($encodedMetadata)->not->toContain($secret);
|
||||
});
|
||||
|
||||
it('returns 404 when attempting to inline-edit a connection belonging to a different tenant', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $otherTenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $otherTenant->tenant_id,
|
||||
'display_name' => 'Other tenant connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'old-client-id',
|
||||
'client_secret' => 'top-secret-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'connection',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||
'display_name' => 'Updated name',
|
||||
'client_id' => 'new-client-id',
|
||||
])
|
||||
->assertStatus(404);
|
||||
});
|
||||
175
tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
Normal file
175
tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders clustered verification checks issues-first in the onboarding wizard verify step', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'external_id' => 'tenant-clusters-a',
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$checks = [
|
||||
[
|
||||
'key' => 'provider.connection.check',
|
||||
'title' => 'Provider connection check',
|
||||
'status' => 'pass',
|
||||
'severity' => 'info',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Connection is healthy.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'ext.missing_permission',
|
||||
'message' => 'Missing required application permissions.',
|
||||
'evidence' => [
|
||||
['kind' => 'missing_permission', 'value' => 'DeviceManagementConfiguration.Read.All'],
|
||||
],
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_configuration',
|
||||
'title' => 'Intune configuration access',
|
||||
'status' => 'pass',
|
||||
'severity' => 'info',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'All required permissions are granted.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$verificationReport = VerificationReportWriter::build('provider.connection.check', $checks);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
'verification_report' => $verificationReport,
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Technical details')
|
||||
->assertSee('Admin consent granted')
|
||||
->assertSee('Open required permissions')
|
||||
->assertSee('Issues')
|
||||
->assertSee($entraTenantId);
|
||||
});
|
||||
|
||||
it('can open the onboarding verification technical details slideover without errors', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'external_id' => 'tenant-clusters-b',
|
||||
'status' => 'onboarding',
|
||||
]);
|
||||
|
||||
$verificationReport = VerificationReportWriter::build('provider.connection.check', []);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
'verification_report' => $verificationReport,
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(ManagedTenantOnboardingWizard::class)
|
||||
->mountAction('wizardVerificationTechnicalDetails')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -131,3 +132,132 @@
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee($entraTenantId);
|
||||
});
|
||||
|
||||
it('clears the stored verification run id when switching provider connections', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '12121212-1212-1212-1212-121212121212';
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
$component->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Acme',
|
||||
]);
|
||||
|
||||
$component->call('createProviderConnection', [
|
||||
'display_name' => 'Acme connection',
|
||||
'client_id' => '00000000-0000-0000-0000-000000000000',
|
||||
'client_secret' => 'super-secret',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||
|
||||
$otherConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'dummy',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Dummy connection',
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$component->call('startVerification');
|
||||
|
||||
$session = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('entra_tenant_id', $entraTenantId)
|
||||
->whereNull('completed_at')
|
||||
->firstOrFail();
|
||||
|
||||
expect($session->state['verification_operation_run_id'] ?? null)->toBeInt();
|
||||
|
||||
$component->call('selectProviderConnection', (int) $otherConnection->getKey());
|
||||
|
||||
$session->refresh();
|
||||
|
||||
expect($session->state['verification_operation_run_id'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('treats a completed verification run as stale when it belongs to a different provider connection', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '13131313-1313-1313-1313-131313131313';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$microsoftConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Microsoft connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$otherConnection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'dummy',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Dummy connection',
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $microsoftConnection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $otherConnection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||
|
||||
expect($component->instance()->verificationSucceeded())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -109,6 +109,104 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses provider connection credentials when refreshing observed permissions', function (): void {
|
||||
$graph = new class implements GraphClientInterface
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $servicePrincipalPermissionOptions = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, data: ['id' => 'org-id', 'displayName' => 'Contoso']);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
$this->servicePrincipalPermissionOptions = $options;
|
||||
|
||||
return new GraphResponse(true, data: ['permissions' => []]);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(GraphClientInterface::class, $graph);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$tenant->update([
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'status' => 'needs_consent',
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => $connection->getKey(),
|
||||
'type' => 'client_secret',
|
||||
'payload' => [
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
|
||||
|
||||
expect($graph->servicePrincipalPermissionOptions)->toMatchArray([
|
||||
'tenant' => $connection->entra_tenant_id,
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
]);
|
||||
});
|
||||
|
||||
it('categorizes auth failures and stores sanitized reason codes and messages', function (): void {
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
{
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('renders guidance, admin consent link, re-run verification, and copy actions on the required permissions page', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'external_id' => 'tenant-copy-actions-a',
|
||||
'app_client_id' => null,
|
||||
]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('Guidance')
|
||||
->assertSee('Who can fix this?', false)
|
||||
->assertSee('Admin consent guide')
|
||||
->assertSee('learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent', false)
|
||||
->assertSee('Re-run verification')
|
||||
->assertSee('Copy missing application permissions')
|
||||
->assertSee('Copy missing delegated permissions');
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
it('renders the required permissions page without Graph or outbound HTTP calls', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
assertNoOutboundHttp(function () use ($user, $tenant): void {
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Models\TenantPermission;
|
||||
|
||||
it('narrows required permissions results using filters and search', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
config()->set('intune_permissions.permissions', [
|
||||
[
|
||||
'key' => 'Alpha.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Alpha application permission',
|
||||
'features' => ['backup'],
|
||||
],
|
||||
[
|
||||
'key' => 'Beta.Read.All',
|
||||
'type' => 'delegated',
|
||||
'description' => 'Beta delegated permission',
|
||||
'features' => ['restore'],
|
||||
],
|
||||
[
|
||||
'key' => 'Gamma.Manage.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Gamma restore permission',
|
||||
'features' => ['backup', 'restore'],
|
||||
],
|
||||
]);
|
||||
|
||||
TenantPermission::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Alpha.Read.All',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'db'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
TenantPermission::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Beta.Read.All',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'db'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
TenantPermission::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Gamma.Manage.All',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'db'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$missingResponse = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('All required permissions are present', false);
|
||||
|
||||
$missingResponse
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
|
||||
$presentResponse = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present")
|
||||
->assertSuccessful()
|
||||
->assertSee('wire:model.live="status"', false);
|
||||
|
||||
$presentResponse
|
||||
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
|
||||
$delegatedResponse = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present&type=delegated")
|
||||
->assertSuccessful();
|
||||
|
||||
$delegatedResponse
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
|
||||
$featureQuery = http_build_query([
|
||||
'status' => 'all',
|
||||
'features' => ['backup'],
|
||||
]);
|
||||
|
||||
$featureResponse = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}")
|
||||
->assertSuccessful();
|
||||
|
||||
$featureResponse
|
||||
->assertSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertSee('data-permission-key="Gamma.Manage.All"', false)
|
||||
->assertDontSee('data-permission-key="Beta.Read.All"', false);
|
||||
|
||||
$searchResponse = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=all&search=delegated")
|
||||
->assertSuccessful();
|
||||
|
||||
$searchResponse
|
||||
->assertSee('data-permission-key="Beta.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Alpha.Read.All"', false)
|
||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Models\TenantPermission;
|
||||
|
||||
it('renders required permissions overview with missing-first ordering and clickable feature cards', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$configured = config('intune_permissions.permissions', []);
|
||||
if (! is_array($configured) || count($configured) < 2) {
|
||||
test()->markTestSkipped('Need at least 2 required permissions configured.');
|
||||
}
|
||||
|
||||
$grantedKey = (string) ($configured[0]['key'] ?? '');
|
||||
$missingKey = (string) ($configured[1]['key'] ?? '');
|
||||
|
||||
if ($grantedKey === '' || $missingKey === '') {
|
||||
test()->markTestSkipped('Configured permission keys missing.');
|
||||
}
|
||||
|
||||
TenantPermission::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => $grantedKey,
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'db'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('Blocked', false)
|
||||
->assertSee('applyFeatureFilter', false)
|
||||
->assertSeeInOrder([$missingKey, $grantedKey], false);
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
it('returns 404 for non-members accessing required permissions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$otherTenant->external_id}/required-permissions")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for members without tenant.view capability accessing required permissions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->mock(CapabilityResolver::class, function ($mock): void {
|
||||
$mock->shouldReceive('isMember')
|
||||
->andReturn(true);
|
||||
|
||||
$mock->shouldReceive('can')
|
||||
->andReturnUsing(fn ($user, $tenant, $capability): bool => $capability !== Capabilities::TENANT_VIEW);
|
||||
});
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
@ -132,6 +133,19 @@
|
||||
'id' => 'org_123',
|
||||
'displayName' => 'Org 123',
|
||||
], 200));
|
||||
|
||||
$required = collect(config('intune_permissions.permissions', []))
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): string => (string) ($row['key'] ?? ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$mock->shouldReceive('getServicePrincipalPermissions')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(true, [
|
||||
'permissions' => $required,
|
||||
], 200));
|
||||
});
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
@ -157,9 +171,202 @@
|
||||
|
||||
expect($report)->toBeArray();
|
||||
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||
expect($report['summary']['counts'] ?? [])->toMatchArray([
|
||||
'total' => 1,
|
||||
'pass' => 1,
|
||||
'fail' => 0,
|
||||
]);
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||||
$checkKeys = collect($checks)
|
||||
->filter(fn ($check) => is_array($check))
|
||||
->map(fn (array $check): string => (string) ($check['key'] ?? ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($checkKeys)->toContain('provider.connection.check');
|
||||
expect($checkKeys)->toContain('permissions.admin_consent');
|
||||
|
||||
$counts = is_array($report['summary']['counts'] ?? null) ? $report['summary']['counts'] : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(count($checks));
|
||||
expect((int) ($counts['total'] ?? 0))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('degrades permission clusters to warnings when live permissions refresh is throttled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'client_id' => fake()->uuid(),
|
||||
'client_secret' => fake()->sha1(),
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||
$mock->shouldReceive('getOrganization')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(true, [
|
||||
'id' => 'org_123',
|
||||
'displayName' => 'Org 123',
|
||||
], 200));
|
||||
|
||||
$mock->shouldReceive('getServicePrincipalPermissions')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(false, [], 429, ['Too Many Requests']));
|
||||
});
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
||||
runs: app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run = $run->fresh();
|
||||
|
||||
expect($run?->outcome)->toBe('succeeded');
|
||||
|
||||
$context = is_array($run?->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
expect($report)->toBeArray();
|
||||
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||||
|
||||
$adminConsentCheck = collect($checks)
|
||||
->filter(fn (mixed $check): bool => is_array($check))
|
||||
->firstWhere('key', 'permissions.admin_consent');
|
||||
|
||||
expect($adminConsentCheck)->toBeArray();
|
||||
expect($adminConsentCheck['status'] ?? null)->toBe('warn');
|
||||
expect($adminConsentCheck['blocking'] ?? null)->toBeFalse();
|
||||
expect($adminConsentCheck['reason_code'] ?? null)->toBe('throttled');
|
||||
expect((string) ($adminConsentCheck['message'] ?? ''))->toContain('Unable to refresh observed permissions inventory');
|
||||
|
||||
expect(TenantPermission::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('degrades permission clusters to warnings when live permissions refresh cannot map assignments', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'client_id' => fake()->uuid(),
|
||||
'client_secret' => fake()->sha1(),
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||
$mock->shouldReceive('getOrganization')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(true, [
|
||||
'id' => 'org_123',
|
||||
'displayName' => 'Org 123',
|
||||
], 200));
|
||||
|
||||
$mock->shouldReceive('getServicePrincipalPermissions')
|
||||
->twice()
|
||||
->andReturn(new GraphResponse(true, [
|
||||
'permissions' => [],
|
||||
'diagnostics' => [
|
||||
'assignments_total' => 1,
|
||||
'mapped_total' => 0,
|
||||
'graph_roles_total' => 1,
|
||||
],
|
||||
], 200));
|
||||
});
|
||||
|
||||
$job = new ProviderConnectionHealthCheckJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
||||
runs: app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$credential = ProviderCredential::query()
|
||||
->where('provider_connection_id', (int) $connection->getKey())
|
||||
->first();
|
||||
|
||||
$payload = is_array($credential?->payload) ? $credential->payload : [];
|
||||
|
||||
$comparison = app(\App\Services\Intune\TenantPermissionService::class)->compare(
|
||||
$tenant,
|
||||
persist: true,
|
||||
liveCheck: true,
|
||||
useConfiguredStub: false,
|
||||
graphOptions: [
|
||||
'tenant' => (string) $connection->entra_tenant_id,
|
||||
'client_id' => (string) ($payload['client_id'] ?? ''),
|
||||
'client_secret' => (string) ($payload['client_secret'] ?? ''),
|
||||
],
|
||||
);
|
||||
|
||||
expect($comparison['live_check']['reason_code'] ?? null)->toBe('permission_mapping_failed');
|
||||
|
||||
$run = $run->fresh();
|
||||
|
||||
$context = is_array($run?->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
expect($report)->toBeArray();
|
||||
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||||
|
||||
$adminConsentCheck = collect($checks)
|
||||
->filter(fn (mixed $check): bool => is_array($check))
|
||||
->firstWhere('key', 'permissions.admin_consent');
|
||||
|
||||
expect($adminConsentCheck)->toBeArray();
|
||||
expect($adminConsentCheck['status'] ?? null)->toBe('warn');
|
||||
expect($adminConsentCheck['blocking'] ?? null)->toBeFalse();
|
||||
expect($adminConsentCheck['reason_code'] ?? null)->toBeIn(['permission_mapping_failed', 'unknown_error']);
|
||||
expect((string) ($adminConsentCheck['message'] ?? ''))->toContain('Unable to refresh observed permissions inventory');
|
||||
|
||||
expect(TenantPermission::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
26
tests/Unit/RequiredPermissionsLinksTest.php
Normal file
26
tests/Unit/RequiredPermissionsLinksTest.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
it('builds a tenant-scoped required permissions link without filters', function (): void {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'external_id' => 'tenant-123',
|
||||
]);
|
||||
|
||||
expect(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->toBe('/admin/t/tenant-123/required-permissions');
|
||||
});
|
||||
|
||||
it('builds a tenant-scoped required permissions link with filters', function (): void {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'external_id' => 'tenant 123',
|
||||
]);
|
||||
|
||||
$url = RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'all',
|
||||
'type' => 'application',
|
||||
]);
|
||||
|
||||
expect($url)->toBe('/admin/t/tenant+123/required-permissions?status=all&type=application');
|
||||
});
|
||||
133
tests/Unit/TenantPermissionCheckClustersTest.php
Normal file
133
tests/Unit/TenantPermissionCheckClustersTest.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('marks a cluster as failed and blocking when an application permission is missing', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-a']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks($tenant, [
|
||||
[
|
||||
'key' => 'Directory.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$directoryCheck = collect($checks)->firstWhere('key', 'permissions.directory_groups');
|
||||
|
||||
expect($directoryCheck)->toBeArray();
|
||||
expect($directoryCheck['status'] ?? null)->toBe(VerificationCheckStatus::Fail->value);
|
||||
expect($directoryCheck['blocking'] ?? null)->toBeTrue();
|
||||
|
||||
expect($directoryCheck['next_steps'][0]['url'] ?? null)
|
||||
->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||
});
|
||||
|
||||
it('marks a cluster as warn and non-blocking when only delegated permissions are missing', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-b']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks($tenant, [
|
||||
[
|
||||
'key' => 'DeviceManagementApps.Read.All',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$appsCheck = collect($checks)->firstWhere('key', 'permissions.intune_apps');
|
||||
|
||||
expect($appsCheck)->toBeArray();
|
||||
expect($appsCheck['status'] ?? null)->toBe(VerificationCheckStatus::Warn->value);
|
||||
expect($appsCheck['blocking'] ?? null)->toBeFalse();
|
||||
});
|
||||
|
||||
it('marks a cluster as skipped when no mapped permissions are present', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-c']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks($tenant, []);
|
||||
|
||||
$rbacCheck = collect($checks)->firstWhere('key', 'permissions.intune_rbac_assignments');
|
||||
|
||||
expect($rbacCheck)->toBeArray();
|
||||
expect($rbacCheck['status'] ?? null)->toBe(VerificationCheckStatus::Skip->value);
|
||||
});
|
||||
|
||||
it('marks a cluster as passed when all mapped permissions are granted', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-d']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks($tenant, [
|
||||
[
|
||||
'key' => 'Directory.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$directoryCheck = collect($checks)->firstWhere('key', 'permissions.directory_groups');
|
||||
|
||||
expect($directoryCheck)->toBeArray();
|
||||
expect($directoryCheck['status'] ?? null)->toBe(VerificationCheckStatus::Pass->value);
|
||||
expect($directoryCheck['next_steps'] ?? null)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('degrades permission clusters to warnings when inventory is not fresh', function (): void {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-e']);
|
||||
|
||||
$checks = TenantPermissionCheckClusters::buildChecks(
|
||||
tenant: $tenant,
|
||||
permissions: [
|
||||
[
|
||||
'key' => 'Directory.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => [],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
],
|
||||
inventory: [
|
||||
'fresh' => false,
|
||||
'reason_code' => 'throttled',
|
||||
'message' => 'Unable to refresh observed permissions inventory during this run. Retry verification.',
|
||||
],
|
||||
);
|
||||
|
||||
$adminConsentCheck = collect($checks)->firstWhere('key', 'permissions.admin_consent');
|
||||
|
||||
expect($adminConsentCheck)->toBeArray();
|
||||
expect($adminConsentCheck['status'] ?? null)->toBe(VerificationCheckStatus::Warn->value);
|
||||
expect($adminConsentCheck['blocking'] ?? null)->toBeFalse();
|
||||
expect($adminConsentCheck['reason_code'] ?? null)->toBe('throttled');
|
||||
expect((string) ($adminConsentCheck['message'] ?? ''))->toContain('Unable to refresh observed permissions inventory');
|
||||
});
|
||||
113
tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
Normal file
113
tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
|
||||
it('builds copy payload as newline-separated missing permissions for the selected type', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'DeviceManagementApps.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'DeviceManagementApps.ReadWrite.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['directory-groups'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'Policy.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['conditional-access'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$payload = TenantRequiredPermissionsViewModelBuilder::deriveCopyPayload($rows, 'application');
|
||||
|
||||
expect($payload)->toBe(implode("\n", [
|
||||
'DeviceManagementApps.Read.All',
|
||||
'Policy.Read.All',
|
||||
]));
|
||||
});
|
||||
|
||||
it('respects the feature filter for copy payload', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'A',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['f1', 'f2'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'B',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['f2'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'C',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['f3'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$payload = TenantRequiredPermissionsViewModelBuilder::deriveCopyPayload($rows, 'delegated', ['f2']);
|
||||
|
||||
expect($payload)->toBe(implode("\n", [
|
||||
'A',
|
||||
'B',
|
||||
]));
|
||||
});
|
||||
|
||||
it('ignores search terms for copy payload but respects the feature filter', function (): void {
|
||||
$permissionService = \Mockery::mock(\App\Services\Intune\TenantPermissionService::class);
|
||||
$permissionService->shouldReceive('compare')
|
||||
->andReturn([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
[
|
||||
'key' => 'Match.Me',
|
||||
'type' => 'application',
|
||||
'description' => 'Some description',
|
||||
'features' => ['f1'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$builder = new TenantRequiredPermissionsViewModelBuilder($permissionService);
|
||||
$tenant = Tenant::factory()->make(['external_id' => 'tenant-copy-a', 'name' => 'Tenant']);
|
||||
|
||||
$vm = $builder->build($tenant, [
|
||||
'features' => ['f1'],
|
||||
'search' => 'does not match',
|
||||
]);
|
||||
|
||||
expect($vm['permissions'])->toBeEmpty();
|
||||
expect($vm['copy']['application'])->toBe('Match.Me');
|
||||
});
|
||||
67
tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
Normal file
67
tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
|
||||
it('aggregates feature impacts without double-counting within a feature', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'P1',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'P2',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'P3',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup', 'drift'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'P4',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['drift'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'P5',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup', 'backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$impacts = TenantRequiredPermissionsViewModelBuilder::deriveFeatureImpacts($rows);
|
||||
$byFeature = collect($impacts)->keyBy('feature')->all();
|
||||
|
||||
expect($byFeature['backup'])->toMatchArray([
|
||||
'feature' => 'backup',
|
||||
'missing' => 3,
|
||||
'required_application' => 3,
|
||||
'required_delegated' => 1,
|
||||
'blocked' => true,
|
||||
]);
|
||||
|
||||
expect($byFeature['drift'])->toMatchArray([
|
||||
'feature' => 'drift',
|
||||
'missing' => 1,
|
||||
'required_application' => 2,
|
||||
'required_delegated' => 0,
|
||||
'blocked' => true,
|
||||
]);
|
||||
});
|
||||
87
tests/Unit/TenantRequiredPermissionsFilteringTest.php
Normal file
87
tests/Unit/TenantRequiredPermissionsFilteringTest.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
|
||||
it('filters and sorts permissions by status, type, features, and search', function (): void {
|
||||
$permissions = [
|
||||
[
|
||||
'key' => 'alpha.read',
|
||||
'type' => 'application',
|
||||
'description' => 'Alpha application permission',
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'beta.read',
|
||||
'type' => 'delegated',
|
||||
'description' => 'Beta delegated permission',
|
||||
'features' => ['restore'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'gamma.manage',
|
||||
'type' => 'application',
|
||||
'description' => 'Gamma restore permission',
|
||||
'features' => ['backup', 'restore'],
|
||||
'status' => 'error',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => 'all',
|
||||
'type' => 'all',
|
||||
'features' => [],
|
||||
'search' => '',
|
||||
]);
|
||||
|
||||
$filtered = TenantRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $state);
|
||||
|
||||
expect(array_column($filtered, 'key'))->toBe([
|
||||
'alpha.read',
|
||||
'gamma.manage',
|
||||
'beta.read',
|
||||
]);
|
||||
|
||||
$missingState = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => 'missing',
|
||||
'type' => 'all',
|
||||
'features' => ['backup'],
|
||||
'search' => '',
|
||||
]);
|
||||
|
||||
$missing = TenantRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $missingState);
|
||||
|
||||
expect(array_column($missing, 'key'))->toBe([
|
||||
'alpha.read',
|
||||
'gamma.manage',
|
||||
]);
|
||||
|
||||
$presentDelegatedState = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => 'present',
|
||||
'type' => 'delegated',
|
||||
'features' => [],
|
||||
'search' => '',
|
||||
]);
|
||||
|
||||
$presentDelegated = TenantRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $presentDelegatedState);
|
||||
|
||||
expect(array_column($presentDelegated, 'key'))->toBe([
|
||||
'beta.read',
|
||||
]);
|
||||
|
||||
$searchState = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => 'all',
|
||||
'type' => 'all',
|
||||
'features' => [],
|
||||
'search' => 'RESTORE',
|
||||
]);
|
||||
|
||||
$search = TenantRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $searchState);
|
||||
|
||||
expect(array_column($search, 'key'))->toBe([
|
||||
'gamma.manage',
|
||||
]);
|
||||
});
|
||||
100
tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
Normal file
100
tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
it('maps overall to blocked when any application permission is missing', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'A',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'B',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
||||
->toBe(VerificationReportOverall::Blocked->value);
|
||||
});
|
||||
|
||||
it('maps overall to needs_attention when only delegated permissions are missing', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'A',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'B',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'missing',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
||||
->toBe(VerificationReportOverall::NeedsAttention->value);
|
||||
});
|
||||
|
||||
it('maps overall to needs_attention when any permission is in error', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'A',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'B',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'error',
|
||||
'details' => ['source' => 'graph_api'],
|
||||
],
|
||||
];
|
||||
|
||||
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
||||
->toBe(VerificationReportOverall::NeedsAttention->value);
|
||||
});
|
||||
|
||||
it('maps overall to ready when nothing is missing', function (): void {
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'A',
|
||||
'type' => 'application',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
[
|
||||
'key' => 'B',
|
||||
'type' => 'delegated',
|
||||
'description' => null,
|
||||
'features' => ['backup'],
|
||||
'status' => 'granted',
|
||||
'details' => null,
|
||||
],
|
||||
];
|
||||
|
||||
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
||||
->toBe(VerificationReportOverall::Ready->value);
|
||||
});
|
||||
50
tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php
Normal file
50
tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
|
||||
it('preserves safe evidence pointer kinds for app diagnostics', function (): void {
|
||||
$report = [
|
||||
'schema_version' => '1',
|
||||
'flow' => 'managed_tenant_onboarding',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'summary' => [
|
||||
'overall' => 'warn',
|
||||
'counts' => [
|
||||
'total' => 1,
|
||||
'pass' => 0,
|
||||
'fail' => 0,
|
||||
'warn' => 1,
|
||||
'skip' => 0,
|
||||
'running' => 0,
|
||||
],
|
||||
],
|
||||
'checks' => [
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'status' => 'warn',
|
||||
'severity' => 'medium',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'permissions_inventory_empty',
|
||||
'message' => 'No permissions detected.',
|
||||
'evidence' => [
|
||||
['kind' => 'app_id', 'value' => '00000000-0000-0000-0000-000000000000'],
|
||||
['kind' => 'observed_permissions_count', 'value' => 0],
|
||||
['kind' => 'client_secret', 'value' => 'nope'],
|
||||
],
|
||||
'next_steps' => [],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sanitized = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
$evidence = $sanitized['checks'][0]['evidence'] ?? null;
|
||||
|
||||
expect($evidence)->toBeArray();
|
||||
expect($evidence)->toContain(['kind' => 'app_id', 'value' => '00000000-0000-0000-0000-000000000000']);
|
||||
expect($evidence)->toContain(['kind' => 'observed_permissions_count', 'value' => 0]);
|
||||
expect($evidence)->not->toContain(['kind' => 'client_secret', 'value' => 'nope']);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user