Compare commits
4 Commits
073-unifie
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fb1046c97a | |||
| 05a604cfb6 | |||
| 53dc89e6ef | |||
| 8e34b6084f |
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@ -69,6 +70,8 @@ public function selectTenant(int $tenantId): void
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
|
||||
@ -8,11 +8,13 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ChooseWorkspace extends Page
|
||||
{
|
||||
@ -37,6 +39,12 @@ protected function getHeaderActions(): array
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& Gate::forUser($user)->check('create', Workspace::class);
|
||||
})
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
@ -100,7 +108,9 @@ public function selectWorkspace(int $workspaceId): void
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,6 +124,8 @@ public function createWorkspace(array $data): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Gate::forUser($user)->authorize('create', Workspace::class);
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
@ -132,7 +144,9 @@ public function createWorkspace(array $data): void
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||
}
|
||||
|
||||
private function redirectAfterWorkspaceSelected(User $user): string
|
||||
|
||||
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class Alerts extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alerts';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static ?string $slug = 'alerts';
|
||||
|
||||
protected static ?string $title = 'Alerts';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
}
|
||||
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class AuditLog extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Audit Log';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
protected static ?string $slug = 'audit-log';
|
||||
|
||||
protected static ?string $title = 'Audit Log';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
}
|
||||
@ -1,22 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
@ -26,6 +25,8 @@ class Operations extends Page implements HasForms, HasTable
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
public string $activeTab = 'all';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||
@ -37,89 +38,62 @@ class Operations extends Page implements HasForms, HasTable
|
||||
// Must be non-static
|
||||
protected string $view = 'filament.pages.monitoring.operations';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
OperationsKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
OperationRun::query()
|
||||
->where('tenant_id', Filament::getTenant()->id)
|
||||
->latest('created_at')
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
->latest('id')
|
||||
->when(
|
||||
$workspaceId,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('type')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->when(
|
||||
! $workspaceId,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
|
||||
TextColumn::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->label('Started'),
|
||||
|
||||
TextColumn::make('duration')
|
||||
->getStateUsing(function (OperationRun $record) {
|
||||
if ($record->started_at && $record->completed_at) {
|
||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
||||
return $this->applyActiveTab($query);
|
||||
});
|
||||
}
|
||||
|
||||
return '-';
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('outcome')
|
||||
->options([
|
||||
'succeeded' => 'Succeeded',
|
||||
'partially_succeeded' => 'Partially Succeeded',
|
||||
'failed' => 'Failed',
|
||||
'cancelled' => 'Cancelled',
|
||||
'pending' => 'Pending',
|
||||
private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
'active' => $query->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
]),
|
||||
|
||||
SelectFilter::make('type')
|
||||
->options(
|
||||
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
||||
->distinct()
|
||||
->pluck('type', 'type')
|
||||
->toArray()
|
||||
),
|
||||
|
||||
Filter::make('created_at')
|
||||
->form([
|
||||
DatePicker::make('created_from'),
|
||||
DatePicker::make('created_until'),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return $query
|
||||
->when(
|
||||
$data['created_from'],
|
||||
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
||||
)
|
||||
->when(
|
||||
$data['created_until'],
|
||||
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
||||
);
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
// View action handled by opening a modal or side-peek
|
||||
]);
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||
'partial' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
||||
'failed' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Filament\Pages\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
@ -29,13 +31,39 @@ class TenantlessOperationRunViewer extends Page
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$actions = [
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => url()->current()),
|
||||
];
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$tenant = $this->run->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$actions[] = Action::make('admin_details')
|
||||
->label('Admin details')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('filament.admin.resources.operations.view', [
|
||||
'tenant' => (int) $tenant->getKey(),
|
||||
'record' => (int) $this->run->getKey(),
|
||||
]));
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
|
||||
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,8 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -14,13 +16,16 @@
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
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;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
@ -28,6 +33,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
@ -48,9 +54,11 @@
|
||||
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;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
@ -214,7 +222,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,12 +339,16 @@ 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->verificationStatus() !== 'in_progress')
|
||||
->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
|
||||
@ -294,7 +356,7 @@ public function content(Schema $schema): Schema
|
||||
->action(fn () => $this->startVerification()),
|
||||
Action::make('wizardRefreshVerification')
|
||||
->label('Refresh')
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null)
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress')
|
||||
->action(fn () => $this->refreshVerificationStatus()),
|
||||
]),
|
||||
ViewField::make('verification_report')
|
||||
@ -560,6 +622,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 +667,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(
|
||||
@ -629,7 +703,22 @@ private function verificationRunUrl(): ?string
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{run: array<string, mixed>|null, runUrl: string|null}
|
||||
* @return array{
|
||||
* run: array<string, mixed>|null,
|
||||
* runUrl: string|null,
|
||||
* report: array<string, mixed>|null,
|
||||
* fingerprint: string|null,
|
||||
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
|
||||
* previousRunUrl: string|null,
|
||||
* canAcknowledge: bool,
|
||||
* acknowledgements: array<string, array{
|
||||
* check_key: string,
|
||||
* ack_reason: string,
|
||||
* acknowledged_at: string|null,
|
||||
* expires_at: string|null,
|
||||
* acknowledged_by: array{id: int, name: string}|null
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private function verificationReportViewData(): array
|
||||
{
|
||||
@ -640,14 +729,60 @@ private function verificationReportViewData(): array
|
||||
return [
|
||||
'run' => null,
|
||||
'runUrl' => $runUrl,
|
||||
'report' => null,
|
||||
'fingerprint' => null,
|
||||
'changeIndicator' => null,
|
||||
'previousRunUrl' => null,
|
||||
'canAcknowledge' => false,
|
||||
'acknowledgements' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
||||
$previousRunUrl = $changeIndicator === null
|
||||
? null
|
||||
: $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']);
|
||||
|
||||
$user = auth()->user();
|
||||
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
|
||||
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
|
||||
: false;
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
@ -662,9 +797,152 @@ private function verificationReportViewData(): array
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
'report' => $report,
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
}
|
||||
|
||||
public function acknowledgeVerificationCheckAction(): Action
|
||||
{
|
||||
return Action::make('acknowledgeVerificationCheck')
|
||||
->label('Acknowledge')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Acknowledge issue')
|
||||
->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.')
|
||||
->form([
|
||||
Textarea::make('ack_reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->maxLength(160)
|
||||
->rows(3),
|
||||
TextInput::make('expires_at')
|
||||
->label('Expiry (optional)')
|
||||
->helperText('Optional timestamp (informational only).')
|
||||
->maxLength(64),
|
||||
])
|
||||
->action(function (array $data, array $arguments): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$checkKey = (string) ($arguments['check_key'] ?? '');
|
||||
$ackReason = (string) ($data['ack_reason'] ?? '');
|
||||
$expiresAt = $data['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) ? $expiresAt : null;
|
||||
|
||||
try {
|
||||
app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
||||
tenant: $tenant,
|
||||
run: $run,
|
||||
checkKey: $checkKey,
|
||||
ackReason: $ackReason,
|
||||
expiresAt: $expiresAt,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
Notification::make()
|
||||
->title('Unable to acknowledge')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Issue acknowledged')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(function (array $arguments): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checkKey = trim((string) ($arguments['check_key'] ?? ''));
|
||||
|
||||
if ($checkKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ackExists = VerificationCheckAcknowledgement::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->where('check_key', $checkKey)
|
||||
->exists();
|
||||
|
||||
if ($ackExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
|
||||
if (! is_array($report)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($check['key'] ?? null) !== $checkKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
return is_string($status) && in_array($status, [
|
||||
VerificationCheckStatus::Fail->value,
|
||||
VerificationCheckStatus::Warn->value,
|
||||
], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private function bootstrapRunsLabel(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
@ -938,12 +1216,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 ?? [], [
|
||||
$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 +1305,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 ?? [], [
|
||||
$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 +1390,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 +1870,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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,18 +3,24 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -34,6 +40,8 @@ class OperationRunResource extends Resource
|
||||
|
||||
protected static ?string $slug = 'operations';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
@ -42,12 +50,13 @@ class OperationRunResource extends Resource
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
->latest('id')
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
||||
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -143,6 +152,63 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.components.verification-report-viewer')
|
||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||
->viewData(function (OperationRun $record): array {
|
||||
$report = VerificationReportViewer::report($record);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
||||
|
||||
$previousRunUrl = null;
|
||||
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||
}
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||
->where('operation_run_id', (int) $record->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $record->getKey(),
|
||||
'type' => (string) $record->type,
|
||||
'status' => (string) $record->status,
|
||||
'outcome' => (string) $record->outcome,
|
||||
'started_at' => $record->started_at?->toJSON(),
|
||||
'completed_at' => $record->completed_at?->toJSON(),
|
||||
],
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||
@ -211,16 +277,47 @@ public static function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $tenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->options(function (): array {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if (! $tenantId) {
|
||||
if ($workspaceId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->select('type')
|
||||
->distinct()
|
||||
->orderBy('type')
|
||||
@ -238,14 +335,20 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->options(function (): array {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if (! $tenantId) {
|
||||
if ($workspaceId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
||||
? (int) $tenant->getKey()
|
||||
: null;
|
||||
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
||||
->whereNotNull('initiator_name')
|
||||
->select('initiator_name')
|
||||
->distinct()
|
||||
@ -281,7 +384,8 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
Actions\ViewAction::make()
|
||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
@ -290,7 +394,7 @@ public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListOperationRuns::route('/'),
|
||||
'view' => Pages\ViewOperationRun::route('/{record}'),
|
||||
'view' => Pages\ViewOperationRun::route('/r/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -23,6 +24,7 @@ protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantArchivedBanner::class,
|
||||
RecentOperationsSummary::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\Workspaces;
|
||||
|
||||
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -11,6 +12,7 @@
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class WorkspaceResource extends Resource
|
||||
@ -25,10 +27,31 @@ class WorkspaceResource extends Resource
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $breadcrumb = 'Manage workspaces';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->whereNull('archived_at')
|
||||
->whereIn('id', function ($subQuery) use ($user): void {
|
||||
$subQuery->from('workspace_memberships')
|
||||
->select('workspace_id')
|
||||
->where('user_id', $user->getKey());
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
|
||||
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
|
||||
final class VerificationReportChangeIndicator
|
||||
{
|
||||
/**
|
||||
* @return array{state: 'no_changes'|'changed', previous_report_id: int}|null
|
||||
*/
|
||||
public static function forRun(OperationRun $run): ?array
|
||||
{
|
||||
$report = VerificationReportViewer::report($run);
|
||||
|
||||
if ($report === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previousRun = VerificationReportViewer::previousRun($run, $report);
|
||||
|
||||
if ($previousRun === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previousReport = VerificationReportViewer::report($previousRun);
|
||||
|
||||
if ($previousReport === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentFingerprint = VerificationReportViewer::fingerprint($report);
|
||||
$previousFingerprint = VerificationReportViewer::fingerprint($previousReport);
|
||||
|
||||
if ($currentFingerprint === null || $previousFingerprint === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => $currentFingerprint === $previousFingerprint ? 'no_changes' : 'changed',
|
||||
'previous_report_id' => (int) $previousRun->getKey(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
|
||||
@ -31,6 +32,53 @@ public static function report(OperationRun $run): ?array
|
||||
return $report;
|
||||
}
|
||||
|
||||
public static function previousReportId(array $report): ?int
|
||||
{
|
||||
$previousReportId = $report['previous_report_id'] ?? null;
|
||||
|
||||
if (is_int($previousReportId) && $previousReportId > 0) {
|
||||
return $previousReportId;
|
||||
}
|
||||
|
||||
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
|
||||
return (int) trim($previousReportId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function fingerprint(array $report): ?string
|
||||
{
|
||||
$fingerprint = $report['fingerprint'] ?? null;
|
||||
|
||||
if (is_string($fingerprint)) {
|
||||
$fingerprint = strtolower(trim($fingerprint));
|
||||
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
return $fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
return VerificationReportFingerprint::forReport($report);
|
||||
}
|
||||
|
||||
public static function previousRun(OperationRun $run, array $report): ?OperationRun
|
||||
{
|
||||
$previousReportId = self::previousReportId($report);
|
||||
|
||||
if ($previousReportId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previous = OperationRun::query()
|
||||
->whereKey($previousReportId)
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->first();
|
||||
|
||||
return $previous instanceof OperationRun ? $previous : null;
|
||||
}
|
||||
|
||||
public static function shouldRenderForRun(OperationRun $run): bool
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -81,10 +80,10 @@ protected function getStats(): array
|
||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||
Stat::make('Active operations', $activeRuns)
|
||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
||||
->url(route('admin.operations.index')),
|
||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
||||
->url(route('admin.operations.index')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class RecentOperationsSummary extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'tenant' => null,
|
||||
'runs' => collect(),
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->limit(5)
|
||||
->get([
|
||||
'id',
|
||||
'type',
|
||||
'status',
|
||||
'outcome',
|
||||
'created_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
]);
|
||||
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'runs' => $runs,
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ClearTenantContextController
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||
|
||||
return redirect()->to('/admin/operations');
|
||||
}
|
||||
}
|
||||
@ -49,6 +49,8 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@ -44,6 +45,12 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume($request);
|
||||
|
||||
if ($intendedUrl !== null) {
|
||||
return redirect()->to($intendedUrl);
|
||||
}
|
||||
|
||||
$tenantsQuery = $user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@ -28,27 +31,14 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($this->isWorkspaceOptionalPath($request, $path)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin/t/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -73,8 +63,38 @@ public function handle(Request $request, Closure $next): Response
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||
|
||||
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
||||
? '/admin/choose-workspace'
|
||||
: '/admin/no-access';
|
||||
|
||||
if ($target === '/admin/choose-workspace') {
|
||||
WorkspaceIntendedUrl::storeFromRequest($request);
|
||||
}
|
||||
|
||||
return new HttpResponse('', 302, ['Location' => $target]);
|
||||
}
|
||||
|
||||
private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
{
|
||||
if (str_starts_with($path, '/admin/workspaces')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VerificationCheckAcknowledgement extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\VerificationCheckAcknowledgementFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'acknowledged_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,45 +7,54 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class WorkspacePolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
return true;
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Workspace $workspace): bool
|
||||
public function view(User $user, Workspace $workspace): bool|Response
|
||||
{
|
||||
return WorkspaceMembership::query()
|
||||
$isMember = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->exists();
|
||||
|
||||
return $isMember ? Response::allow() : Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
public function create(User $user): bool|Response
|
||||
{
|
||||
return true;
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Workspace $workspace): bool
|
||||
public function update(User $user, Workspace $workspace): bool|Response
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
@ -53,21 +57,50 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Workspaces')
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}),
|
||||
NavigationItem::make('Operations')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||
fn () => view('filament.partials.workspace-switcher')->render()
|
||||
PanelsRenderHook::TOPBAR_START,
|
||||
fn () => view('filament.partials.context-bar')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
|
||||
@ -21,6 +21,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
@ -44,6 +45,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
|
||||
|
||||
@ -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,48 @@ public function compare(
|
||||
$hasErrors = false;
|
||||
$checkedAt = now();
|
||||
|
||||
$canPersist = $persist;
|
||||
|
||||
if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||
// When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot
|
||||
// only if we have no stored inventory yet, so the UI can explain the failure.
|
||||
$reasonCode = is_string($liveCheckMeta['reason_code'] ?? null)
|
||||
? (string) $liveCheckMeta['reason_code']
|
||||
: null;
|
||||
|
||||
$shouldPersistErrorSnapshot = in_array($reasonCode, [
|
||||
'authentication_failed',
|
||||
'permission_denied',
|
||||
], true);
|
||||
|
||||
if (! $shouldPersistErrorSnapshot) {
|
||||
$canPersist = false;
|
||||
} else {
|
||||
$hasStoredStatuses = TenantPermission::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->exists();
|
||||
|
||||
$canPersist = ! $hasStoredStatuses;
|
||||
}
|
||||
}
|
||||
|
||||
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 +207,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 +321,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 +342,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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class VerificationCheckAcknowledgementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkspaceAuditLogger $audit,
|
||||
) {}
|
||||
|
||||
public function acknowledge(
|
||||
Tenant $tenant,
|
||||
OperationRun $run,
|
||||
string $checkKey,
|
||||
string $ackReason,
|
||||
?string $expiresAt,
|
||||
User $actor,
|
||||
): VerificationCheckAcknowledgement {
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant);
|
||||
|
||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$checkKey = trim($checkKey);
|
||||
if ($checkKey === '') {
|
||||
throw new InvalidArgumentException('check_key is required.');
|
||||
}
|
||||
|
||||
$ackReason = trim($ackReason);
|
||||
if ($ackReason === '') {
|
||||
throw new InvalidArgumentException('ack_reason is required.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ackReason) > 160) {
|
||||
throw new InvalidArgumentException('ack_reason must be at most 160 characters.');
|
||||
}
|
||||
|
||||
$report = $this->reportForRun($run);
|
||||
$check = $this->findCheckByKey($report, $checkKey);
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) {
|
||||
throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.');
|
||||
}
|
||||
|
||||
$reasonCode = $check['reason_code'] ?? null;
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
throw new InvalidArgumentException('Check reason_code is required.');
|
||||
}
|
||||
|
||||
$expiresAtParsed = null;
|
||||
|
||||
if ($expiresAt !== null && trim($expiresAt) !== '') {
|
||||
try {
|
||||
$expiresAtParsed = CarbonImmutable::parse($expiresAt);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException('expires_at must be a valid date-time.');
|
||||
}
|
||||
|
||||
if ($expiresAtParsed->isBefore(CarbonImmutable::now())) {
|
||||
throw new InvalidArgumentException('expires_at must be in the future.');
|
||||
}
|
||||
}
|
||||
|
||||
$acknowledgedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$ack = VerificationCheckAcknowledgement::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'check_key' => $checkKey,
|
||||
'ack_reason' => $ackReason,
|
||||
'expires_at' => $expiresAtParsed,
|
||||
'acknowledged_at' => $acknowledgedAt,
|
||||
'acknowledged_by_user_id' => (int) $actor->getKey(),
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
$ack = VerificationCheckAcknowledgement::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->where('check_key', $checkKey)
|
||||
->first();
|
||||
|
||||
if (! $ack instanceof VerificationCheckAcknowledgement) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
if ($ack->wasRecentlyCreated) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null) {
|
||||
$this->audit->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCheckAcknowledged->value,
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'report_id' => (int) $run->getKey(),
|
||||
'flow' => (string) $run->type,
|
||||
'check_key' => $checkKey,
|
||||
'reason_code' => $reasonCode,
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reportForRun(OperationRun $run): array
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
if (! is_array($report)) {
|
||||
throw new InvalidArgumentException('Verification report is missing.');
|
||||
}
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
throw new InvalidArgumentException('Verification report is invalid.');
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findCheckByKey(array $report, string $checkKey): array
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($check['key'] ?? null) === $checkKey) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Check not found in verification report.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,4 +29,5 @@ enum AuditActionId: string
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ private static function sanitizeString(string $value): string
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
|
||||
@ -61,6 +61,9 @@ class Capabilities
|
||||
// Findings
|
||||
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||
|
||||
// Verification
|
||||
public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge';
|
||||
|
||||
// Tenant memberships
|
||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
@ -27,6 +28,14 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
$existingTenant = Filament::getTenant();
|
||||
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
@ -44,6 +53,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($path === '/admin/operations') {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$user = $request->user();
|
||||
|
||||
@ -66,9 +81,6 @@ public function handle(Request $request, Closure $next): Response
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
abort(404);
|
||||
}
|
||||
@ -92,6 +104,9 @@ public function handle(Request $request, Closure $next): Response
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
@ -100,7 +115,8 @@ public function handle(Request $request, Closure $next): Response
|
||||
if (
|
||||
str_starts_with($path, '/admin/w/')
|
||||
|| str_starts_with($path, '/admin/workspaces')
|
||||
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|
||||
|| str_starts_with($path, '/admin/operations')
|
||||
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding'], true)
|
||||
) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
@ -121,60 +137,6 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenant = null;
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
try {
|
||||
$tenant = Tenant::current();
|
||||
} catch (\RuntimeException) {
|
||||
$tenant = null;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
$tenant = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->withTrashed()->first();
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
Filament::setTenant($tenant, true);
|
||||
}
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
@ -195,11 +157,46 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
$panel->navigation(function (): NavigationBuilder {
|
||||
return app(NavigationBuilder::class)
|
||||
->item(
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Operations')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => '/admin/alerts')
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => '/admin/audit-log')
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
@ -18,7 +17,7 @@ final class OperationRunLinks
|
||||
{
|
||||
public static function index(Tenant $tenant): string
|
||||
{
|
||||
return OperationRunResource::getUrl('index', tenant: $tenant);
|
||||
return route('admin.operations.index');
|
||||
}
|
||||
|
||||
public static function tenantlessView(OperationRun|int $run): string
|
||||
@ -30,7 +29,7 @@ public static function tenantlessView(OperationRun|int $run): string
|
||||
|
||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||
{
|
||||
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
|
||||
return self::tenantlessView($run);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class PreviousVerificationReportResolver
|
||||
{
|
||||
public static function resolvePreviousReportId(OperationRun $run): ?int
|
||||
{
|
||||
$runId = $run->getKey();
|
||||
|
||||
if (! is_int($runId) || $runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = self::providerConnectionId($run);
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('type', (string) $run->type)
|
||||
->where('run_identity_hash', (string) $run->run_identity_hash)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('id', '<', $runId)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($providerConnectionId !== null) {
|
||||
$query->where('context->provider_connection_id', $providerConnectionId);
|
||||
} else {
|
||||
$query->whereNull('context->provider_connection_id');
|
||||
}
|
||||
|
||||
$previousId = $query->value('id');
|
||||
|
||||
return is_int($previousId) ? $previousId : null;
|
||||
}
|
||||
|
||||
private static function providerConnectionId(OperationRun $run): ?int
|
||||
{
|
||||
$context = $run->context;
|
||||
|
||||
if (! is_array($context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||
|
||||
if (is_int($providerConnectionId)) {
|
||||
return $providerConnectionId;
|
||||
}
|
||||
|
||||
if (is_string($providerConnectionId) && ctype_digit(trim($providerConnectionId))) {
|
||||
return (int) trim($providerConnectionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Support/Verification/VerificationReportFingerprint.php
Normal file
96
app/Support/Verification/VerificationReportFingerprint.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
*/
|
||||
public static function forChecks(array $checks): string
|
||||
{
|
||||
$tuples = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::normalizeKey($check['key'] ?? null);
|
||||
$status = self::normalizeEnumString($check['status'] ?? null);
|
||||
$reasonCode = self::normalizeEnumString($check['reason_code'] ?? null);
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
$severity = is_string($severity) ? trim($severity) : '';
|
||||
|
||||
if ($severity === '') {
|
||||
$severity = '';
|
||||
} else {
|
||||
$severity = strtolower($severity);
|
||||
}
|
||||
|
||||
$tuples[] = [
|
||||
'key' => $key,
|
||||
'tuple' => implode('|', [
|
||||
$key,
|
||||
$status,
|
||||
$blocking ? '1' : '0',
|
||||
$reasonCode,
|
||||
$severity,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
usort($tuples, static function (array $a, array $b): int {
|
||||
$keyComparison = $a['key'] <=> $b['key'];
|
||||
|
||||
if ($keyComparison !== 0) {
|
||||
return $keyComparison;
|
||||
}
|
||||
|
||||
return $a['tuple'] <=> $b['tuple'];
|
||||
});
|
||||
|
||||
$payload = implode("\n", array_map(static fn (array $item): string => (string) $item['tuple'], $tuples));
|
||||
|
||||
return hash('sha256', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function forReport(array $report): string
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> $checks */
|
||||
return self::forChecks($checks);
|
||||
}
|
||||
|
||||
private static function normalizeKey(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : $value;
|
||||
}
|
||||
|
||||
private static function normalizeEnumString(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : strtolower($value);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
*/
|
||||
@ -39,6 +56,40 @@ public static function sanitizeReport(array $report): array
|
||||
$sanitized['generated_at'] = $generatedAt;
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (is_string($fingerprint)) {
|
||||
$fingerprint = strtolower(trim($fingerprint));
|
||||
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
$sanitized['fingerprint'] = $fingerprint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId === null || is_int($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
} elseif (is_string($previousReportId)) {
|
||||
$previousReportId = trim($previousReportId);
|
||||
|
||||
if ($previousReportId === '') {
|
||||
$sanitized['previous_report_id'] = null;
|
||||
} elseif (ctype_digit($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = (int) $previousReportId;
|
||||
} else {
|
||||
$previousReportId = self::sanitizeShortString($previousReportId, fallback: null);
|
||||
|
||||
if ($previousReportId !== null) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($report['identity'] ?? null)) {
|
||||
$identity = self::sanitizeIdentity((array) $report['identity']);
|
||||
|
||||
@ -164,8 +215,14 @@ private static function sanitizeChecks(array $checks): ?array
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
$severityRaw = $check['severity'] ?? null;
|
||||
if (! is_string($severityRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severityRaw));
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -210,6 +267,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 +280,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 +295,7 @@ private static function sanitizeEvidence(array $evidence): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
final class VerificationReportSchema
|
||||
{
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
@ -78,6 +78,22 @@ public static function isValidReport(array $report): bool
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -137,7 +153,13 @@ private static function isValidCheckResult(array $check): bool
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
if (! is_string($severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = trim($severity);
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,8 @@ public static function write(OperationRun $run, array $checks, array $identity =
|
||||
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
||||
|
||||
$report = self::build($flow, $checks, $identity);
|
||||
$report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run);
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
@ -75,6 +77,8 @@ public static function build(string $flow, array $checks, array $identity = []):
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks),
|
||||
'previous_report_id' => null,
|
||||
'summary' => [
|
||||
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
||||
'counts' => $counts,
|
||||
@ -98,6 +102,8 @@ private static function buildFallbackReport(string $flow): array
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow !== '' ? $flow : 'unknown',
|
||||
'generated_at' => now()->toISOString(),
|
||||
'fingerprint' => VerificationReportFingerprint::forChecks([]),
|
||||
'previous_report_id' => null,
|
||||
'summary' => [
|
||||
'overall' => VerificationReportOverall::NeedsAttention->value,
|
||||
'counts' => [
|
||||
@ -161,14 +167,12 @@ private static function normalizeCheckStatus(mixed $status): string
|
||||
private static function normalizeCheckSeverity(mixed $severity): string
|
||||
{
|
||||
if (! is_string($severity)) {
|
||||
return VerificationCheckSeverity::Info->value;
|
||||
return '';
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severity));
|
||||
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true)
|
||||
? $severity
|
||||
: VerificationCheckSeverity::Info->value;
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
|
||||
}
|
||||
|
||||
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||
|
||||
@ -11,6 +11,10 @@ final class WorkspaceContext
|
||||
{
|
||||
public const SESSION_KEY = 'current_workspace_id';
|
||||
|
||||
public const INTENDED_URL_SESSION_KEY = 'workspace_intended_url';
|
||||
|
||||
public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids';
|
||||
|
||||
public function __construct(private WorkspaceResolver $resolver) {}
|
||||
|
||||
public function currentWorkspaceId(?Request $request = null): ?int
|
||||
@ -53,6 +57,54 @@ public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?R
|
||||
}
|
||||
}
|
||||
|
||||
public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||
$map = is_array($map) ? $map : [];
|
||||
|
||||
$map[(string) $workspaceId] = $tenantId;
|
||||
|
||||
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||
}
|
||||
|
||||
public function lastTenantId(?Request $request = null): ?int
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||
$map = is_array($map) ? $map : [];
|
||||
|
||||
$id = $map[(string) $workspaceId] ?? null;
|
||||
|
||||
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
|
||||
}
|
||||
|
||||
public function clearLastTenantId(?Request $request = null): void
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||
$map = is_array($map) ? $map : [];
|
||||
|
||||
unset($map[(string) $workspaceId]);
|
||||
|
||||
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||
}
|
||||
|
||||
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
126
app/Support/Workspaces/WorkspaceIntendedUrl.php
Normal file
126
app/Support/Workspaces/WorkspaceIntendedUrl.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Workspaces;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Store;
|
||||
|
||||
final class WorkspaceIntendedUrl
|
||||
{
|
||||
/**
|
||||
* Store a safe intended URL (path + query) for returning after workspace selection.
|
||||
*/
|
||||
public static function store(string $pathWithQuery, ?Request $request = null): void
|
||||
{
|
||||
$pathWithQuery = trim($pathWithQuery);
|
||||
|
||||
if ($pathWithQuery === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::isAllowed($pathWithQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = self::session($request);
|
||||
|
||||
if (! $session instanceof Store) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, $pathWithQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the intended URL derived from the current request.
|
||||
*/
|
||||
public static function storeFromRequest(Request $request): void
|
||||
{
|
||||
if (! $request->isMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
$queryString = $request->getQueryString();
|
||||
$pathWithQuery = $queryString ? "{$path}?{$queryString}" : $path;
|
||||
|
||||
self::store($pathWithQuery, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume (read + forget) the intended URL. Returns null if missing or unsafe.
|
||||
*/
|
||||
public static function consume(?Request $request = null): ?string
|
||||
{
|
||||
$session = self::session($request);
|
||||
|
||||
if (! $session instanceof Store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $session->pull(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '' || ! self::isAllowed($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function clear(?Request $request = null): void
|
||||
{
|
||||
$session = self::session($request);
|
||||
|
||||
if (! $session instanceof Store) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||
}
|
||||
|
||||
private static function session(?Request $request = null): ?Store
|
||||
{
|
||||
$session = ($request && $request->hasSession())
|
||||
? $request->session()
|
||||
: session()->driver();
|
||||
|
||||
return $session instanceof Store ? $session : null;
|
||||
}
|
||||
|
||||
private static function isAllowed(string $pathWithQuery): bool
|
||||
{
|
||||
if (str_contains($pathWithQuery, "\n") || str_contains($pathWithQuery, "\r")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $pathWithQuery) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($pathWithQuery, '//')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_starts_with($pathWithQuery, '/admin')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = parse_url($pathWithQuery, PHP_URL_PATH);
|
||||
$path = '/'.ltrim((string) ($path ?? ''), '/');
|
||||
|
||||
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<VerificationCheckAcknowledgement>
|
||||
*/
|
||||
class VerificationCheckAcknowledgementFactory extends Factory
|
||||
{
|
||||
protected $model = VerificationCheckAcknowledgement::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'operation_run_id' => function (): int {
|
||||
return (int) OperationRun::factory()->create()->getKey();
|
||||
},
|
||||
'tenant_id' => function (array $attributes): int {
|
||||
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('tenant_id');
|
||||
},
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('workspace_id');
|
||||
},
|
||||
'check_key' => 'provider_connection.token_acquisition',
|
||||
'ack_reason' => fake()->sentence(6),
|
||||
'expires_at' => null,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('verification_check_acknowledgements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->constrained('operation_runs')->cascadeOnDelete();
|
||||
|
||||
$table->string('check_key');
|
||||
$table->string('ack_reason', 160);
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestampTz('acknowledged_at');
|
||||
$table->foreignId('acknowledged_by_user_id')->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['operation_run_id', 'check_key']);
|
||||
|
||||
$table->index(['tenant_id', 'workspace_id', 'operation_run_id']);
|
||||
$table->index(['operation_run_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('verification_check_acknowledgements');
|
||||
}
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
|
||||
@ -2,14 +2,95 @@
|
||||
$report = isset($getState) ? $getState() : ($report ?? null);
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -21,6 +102,9 @@
|
||||
<div class="mt-1">
|
||||
This run doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
@ -30,6 +114,7 @@
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
@ -53,23 +138,92 @@
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($checks === [])
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
No checks found in this report. Start verification again to generate a fresh report.
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? $message : null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
@ -81,15 +235,15 @@
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$evidence = $check['evidence'] ?? [];
|
||||
$evidence = is_array($evidence) ? $evidence : [];
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="flex cursor-pointer items-start justify-between gap-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
@ -102,6 +256,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
@ -109,36 +269,10 @@
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@if ($evidence !== [] || $nextSteps !== [])
|
||||
<div class="mt-4 space-y-4">
|
||||
@if ($evidence !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Evidence
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($evidence as $pointer)
|
||||
@php
|
||||
$pointer = is_array($pointer) ? $pointer : [];
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
$value = $pointer['value'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
|
||||
<li>
|
||||
<span class="font-medium">{{ $kind }}:</span>
|
||||
<span>{{ is_int($value) ? $value : $value }}</span>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
@ -169,10 +303,188 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium 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>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@if ($run !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,23 @@
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
@ -31,6 +48,87 @@
|
||||
$completedAtLabel = $completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
$ackAction = null;
|
||||
|
||||
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
||||
$ackAction = $this->acknowledgeVerificationCheckAction();
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@ -47,55 +145,422 @@
|
||||
<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.
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
@php
|
||||
$overallSpec = $summary === null
|
||||
? null
|
||||
: \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</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 class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This run doesn’t have a report yet. If it already completed, start verification again.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="{ tab: 'issues' }"
|
||||
class="space-y-4"
|
||||
>
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings
|
||||
</div>
|
||||
|
||||
<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;
|
||||
|
||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
||||
$message = is_string($message) && $message !== '' ? $message : null;
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@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 }}
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($message !== null)
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium 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>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($runUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $runUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open run details
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($targetScope !== [])
|
||||
<div class="mt-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target scope
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="flex flex-col gap-1">
|
||||
@php
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
@ -114,21 +579,16 @@
|
||||
@if ($entraTenantId !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
||||
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($runUrl !== null)
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="{{ $runUrl }}"
|
||||
class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open run details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
@ -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,6 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,36 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'all'"
|
||||
wire:click="$set('activeTab', 'all')"
|
||||
>
|
||||
All
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'active'"
|
||||
wire:click="$set('activeTab', 'active')"
|
||||
>
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
>
|
||||
Succeeded
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
>
|
||||
Partial
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
>
|
||||
Failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -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>
|
||||
174
resources/views/filament/partials/context-bar.blade.php
Normal file
174
resources/views/filament/partials/context-bar.blade.php
Normal file
@ -0,0 +1,174 @@
|
||||
@php
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
/** @var WorkspaceContext $workspaceContext */
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
$workspace = $workspaceContext->currentWorkspace(request());
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$canSeeAllWorkspaceTenants = false;
|
||||
if ($user instanceof User && $workspace) {
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
$canSeeAllWorkspaceTenants = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$tenants = collect();
|
||||
if ($user instanceof User && $workspace) {
|
||||
if ($canSeeAllWorkspaceTenants) {
|
||||
$tenants = Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
} else {
|
||||
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(fn ($tenant): bool => $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey())
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
$currentTenant = Filament::getTenant();
|
||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||
|
||||
$path = '/'.ltrim(request()->path(), '/');
|
||||
$isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/');
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<x-filament::dropdown placement="bottom-start" teleport>
|
||||
<x-slot name="trigger">
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-squares-2x2"
|
||||
>
|
||||
{{ $workspace?->name ?? 'Select workspace' }}
|
||||
</x-filament::button>
|
||||
</x-slot>
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<a
|
||||
href="{{ ChooseWorkspace::getUrl() }}"
|
||||
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Switch workspace
|
||||
</a>
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
|
||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
@if (! $workspace)
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-building-office-2"
|
||||
disabled
|
||||
>
|
||||
Select tenant
|
||||
</x-filament::button>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
|
||||
</div>
|
||||
@elseif ($isTenantScopedRoute)
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-building-office-2"
|
||||
disabled
|
||||
>
|
||||
{{ $currentTenantName ?? 'Tenant' }}
|
||||
</x-filament::button>
|
||||
@else
|
||||
<x-filament::dropdown placement="bottom-start" teleport>
|
||||
<x-slot name="trigger">
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-building-office-2"
|
||||
>
|
||||
{{ $currentTenantName ?? 'Select tenant' }}
|
||||
</x-filament::button>
|
||||
</x-slot>
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<div class="px-3 py-2 space-y-2" x-data="{ query: '' }">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Tenant context
|
||||
@if ($canSeeAllWorkspaceTenants)
|
||||
<span class="text-gray-400">· all workspace tenants</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }}
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
class="fi-input fi-text-input w-full"
|
||||
placeholder="Search tenants…"
|
||||
x-model="query"
|
||||
/>
|
||||
|
||||
<div class="max-h-64 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
@foreach ($tenants as $tenant)
|
||||
<form method="POST" action="{{ route('admin.select-tenant') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
|
||||
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
|
||||
>
|
||||
{{ $tenant->getFilamentName() }}
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($canClearTenantContext)
|
||||
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||
@csrf
|
||||
|
||||
<x-filament::button color="gray" size="sm" outlined>
|
||||
Clear tenant context
|
||||
</x-filament::button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Switching tenants is explicit. Canonical monitoring URLs do not change tenant context.
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
@endif
|
||||
</div>
|
||||
@ -25,7 +25,7 @@
|
||||
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
|
||||
@csrf
|
||||
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Switch workspace</div>
|
||||
|
||||
<select
|
||||
name="workspace_id"
|
||||
@ -40,7 +40,7 @@ class="fi-input fi-select w-full"
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Select a workspace to switch context.</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-filament::dropdown.list>
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
@php
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
/** @var \Illuminate\Support\Collection<int, \App\Models\OperationRun> $runs */
|
||||
/** @var string $operationsIndexUrl */
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-semibold">Recent operations</div>
|
||||
|
||||
<a
|
||||
href="{{ $operationsIndexUrl }}"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
View all operations
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
No operations yet.
|
||||
</div>
|
||||
@else
|
||||
<ul class="mt-3 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
@foreach ($runs as $run)
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $run->created_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>{{ (string) $run->status }}</div>
|
||||
<div>{{ (string) $run->outcome }}</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\ClearTenantContextController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
@ -101,6 +102,10 @@
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||
->post('/admin/select-tenant', SelectTenantController::class)
|
||||
->name('admin.select-tenant');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||
->post('/admin/clear-tenant-context', ClearTenantContextController::class)
|
||||
->name('admin.clear-tenant-context');
|
||||
Route::bind('workspace', function (string $value): Workspace {
|
||||
/** @var WorkspaceResolver $resolver */
|
||||
$resolver = app(WorkspaceResolver::class);
|
||||
@ -132,6 +137,48 @@
|
||||
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||
->name('admin.onboarding');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||
->name('admin.monitoring.alerts');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
||||
->name('admin.monitoring.audit-log');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
|
||||
34
specs/075-verification-v1-5/checklists/requirements.md
Normal file
34
specs/075-verification-v1-5/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Verification Checklist Framework V1.5
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-05
|
||||
**Feature**: [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
|
||||
|
||||
- Reviewed against template used in [specs/074-verification-checklist/spec.md](../../074-verification-checklist/spec.md). No open clarifications remain.
|
||||
@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/acknowledge-check.request.schema.json",
|
||||
"title": "AcknowledgeVerificationCheckRequest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"report_id",
|
||||
"check_key",
|
||||
"ack_reason"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"check_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ack_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"expires_at": {
|
||||
"description": "Optional informational expiry timestamp.",
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/verification-check-acknowledgement.schema.json",
|
||||
"title": "VerificationCheckAcknowledgement",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"report_id",
|
||||
"check_key",
|
||||
"ack_reason",
|
||||
"acknowledged_at",
|
||||
"acknowledged_by"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"description": "OperationRun id that contains the report.",
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"check_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ack_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"expires_at": {
|
||||
"description": "Informational only in v1.5.",
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
},
|
||||
"acknowledged_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"acknowledged_by": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": ["string", "integer"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"email": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/verification-report.v1_5.schema.json",
|
||||
"title": "VerificationReportV1_5",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"flow",
|
||||
"generated_at",
|
||||
"fingerprint",
|
||||
"previous_report_id",
|
||||
"summary",
|
||||
"checks"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"description": "Canonical report identifier. In v1.5 this is the OperationRun id.",
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"description": "Version of the verification report schema (SemVer, major 1).",
|
||||
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"flow": {
|
||||
"type": "string",
|
||||
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
|
||||
},
|
||||
"previous_report_id": {
|
||||
"description": "Previous report id for the same identity (nullable).",
|
||||
"type": ["string", "integer", "null"]
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"description": "Scope identifiers for what is being verified.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"description": "Deterministic SHA-256 hash (lowercase hex) of normalized check outcomes.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "counts"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["ready", "needs_attention", "blocked", "running"],
|
||||
"description": "Overall state derived from check results."
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["total", "pass", "fail", "warn", "skip", "running"],
|
||||
"properties": {
|
||||
"total": { "type": "integer", "minimum": 0 },
|
||||
"pass": { "type": "integer", "minimum": 0 },
|
||||
"fail": { "type": "integer", "minimum": 0 },
|
||||
"warn": { "type": "integer", "minimum": 0 },
|
||||
"skip": { "type": "integer", "minimum": 0 },
|
||||
"running": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"$ref": "#/$defs/CheckResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"CheckResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"title",
|
||||
"status",
|
||||
"severity",
|
||||
"blocking",
|
||||
"reason_code",
|
||||
"message",
|
||||
"evidence",
|
||||
"next_steps"
|
||||
],
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pass", "fail", "warn", "skip", "running"]
|
||||
},
|
||||
"severity": {
|
||||
"description": "Must be included for fingerprint determinism; may be empty string.",
|
||||
"type": "string",
|
||||
"enum": ["", "info", "low", "medium", "high", "critical"]
|
||||
},
|
||||
"blocking": { "type": "boolean" },
|
||||
"reason_code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/EvidencePointer" }
|
||||
},
|
||||
"next_steps": {
|
||||
"type": "array",
|
||||
"description": "Navigation-only CTAs (links) in v1.",
|
||||
"items": { "$ref": "#/$defs/NextStep" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"EvidencePointer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "value"],
|
||||
"properties": {
|
||||
"kind": { "type": "string" },
|
||||
"value": {
|
||||
"description": "Safe pointer value (ID/masked string/hash).",
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NextStep": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "url"],
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
specs/075-verification-v1-5/data-model.md
Normal file
114
specs/075-verification-v1-5/data-model.md
Normal file
@ -0,0 +1,114 @@
|
||||
# Data Model: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Date**: 2026-02-05
|
||||
**Phase**: Phase 1 (Design)
|
||||
**Status**: Draft (design-complete for implementation planning)
|
||||
|
||||
---
|
||||
|
||||
## Existing Entity (reference)
|
||||
|
||||
### OperationRun (existing)
|
||||
|
||||
**Purpose**: Canonical operational record. Verification reports are stored in `operation_runs.context.verification_report` (JSONB).
|
||||
|
||||
**Key fields (relevant)**:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `type` (verification flow identifier)
|
||||
- `run_identity_hash` (identity hash used for active dedupe + identity matching)
|
||||
- `status`
|
||||
- `context` (JSONB)
|
||||
|
||||
**Verification report storage**:
|
||||
- `context.verification_report` (JSON object)
|
||||
|
||||
---
|
||||
|
||||
## New Persistent Entity
|
||||
|
||||
### VerificationCheckAcknowledgement (new table)
|
||||
|
||||
**Table name**: `verification_check_acknowledgements`
|
||||
|
||||
**Purpose**: First-class governance record that a failing/warning check is acknowledged for a specific report (report == operation run).
|
||||
|
||||
**Fields**:
|
||||
- `id` (primary key)
|
||||
- `tenant_id` (FK or scalar; used for tenant-scoped filtering and isolation checks)
|
||||
- `workspace_id` (FK or scalar)
|
||||
- `operation_run_id` (FK to `operation_runs.id`) — the “report”
|
||||
- `check_key` (string)
|
||||
- `ack_reason` (string, max 160)
|
||||
- `expires_at` (timestamp, nullable) — informational only in v1.5
|
||||
- `acknowledged_at` (timestamp)
|
||||
- `acknowledged_by_user_id` (FK to `users.id`)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Uniqueness constraint (required)**:
|
||||
- unique `(operation_run_id, check_key)`
|
||||
|
||||
**Indexes (recommended)**:
|
||||
- `(tenant_id, workspace_id, operation_run_id)`
|
||||
- `(operation_run_id)`
|
||||
|
||||
**Validation rules**:
|
||||
- `ack_reason`: required, string, length ≤ 160
|
||||
- `expires_at`: optional, must be a valid timestamp, should be >= acknowledged_at (implementation may enforce)
|
||||
|
||||
**State transitions**:
|
||||
- Immutable per report/check in v1.5: create once; no update/delete/unack flows.
|
||||
|
||||
---
|
||||
|
||||
## Contracted Document (stored in JSON)
|
||||
|
||||
### VerificationReport (JSON in `OperationRun.context.verification_report`)
|
||||
|
||||
**Purpose**: Structured, versioned report of verification results used by the DB-only viewer.
|
||||
|
||||
**Identity**:
|
||||
- `report_id`: `operation_runs.id`
|
||||
- `previous_report_id`: previous run id for same identity (nullable)
|
||||
|
||||
**New v1.5 fields**:
|
||||
- `fingerprint` (string; lowercase hex; SHA-256)
|
||||
- `previous_report_id` (nullable integer/uuid depending on `OperationRun` PK type)
|
||||
|
||||
**Existing core fields (from 074, reference)**:
|
||||
- `schema_version` (SemVer string; major `1`)
|
||||
- `flow` (verification flow identifier; aligns with `OperationRun.type`)
|
||||
- `generated_at` (timestamp)
|
||||
- `summary` (counts + overall outcome)
|
||||
- `checks[]` (flat array) including:
|
||||
- `key`
|
||||
- `title`
|
||||
- `status` (`pass|fail|warn|skip|running`)
|
||||
- `severity` (`info|low|medium|high|critical` or empty string)
|
||||
- `blocking` (boolean)
|
||||
- `reason_code` (string)
|
||||
- safe evidence pointers
|
||||
- `next_steps[]` (navigation-only links)
|
||||
|
||||
**Fingerprint normalization input** (strict):
|
||||
- Flatten all checks across `checks[]`.
|
||||
- Sort by `check.key`.
|
||||
- Contribute the stable tuple string:
|
||||
- `key|status|blocking|reason_code|severity`
|
||||
- `severity` must always be present (missing normalized to empty string).
|
||||
|
||||
---
|
||||
|
||||
## Derived/Computed View Data (not persisted)
|
||||
|
||||
### Change indicator
|
||||
|
||||
Computed in the viewer by comparing:
|
||||
- current `verification_report.fingerprint`
|
||||
- previous `verification_report.fingerprint`
|
||||
|
||||
States:
|
||||
- no previous report → no indicator
|
||||
- fingerprints match → “No changes since previous verification”
|
||||
- fingerprints differ → “Changed since previous verification”
|
||||
150
specs/075-verification-v1-5/plan.md
Normal file
150
specs/075-verification-v1-5/plan.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Implementation Plan: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
|
||||
|
||||
**Branch**: `075-verification-v1_5` | **Date**: 2026-02-05 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/075-verification-v1-5/spec.md`
|
||||
|
||||
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
|
||||
|
||||
## Summary
|
||||
|
||||
- Extend the existing 074 verification report system with deterministic **fingerprints** and a **previous report** link so the viewer can show “Changed / No changes”.
|
||||
- Introduce per-check **acknowledgements** as first-class records (unique per report + check) with explicit confirmation and audit logging, without changing outcomes (“no greenwashing”).
|
||||
- Update the Verify step UX to be operator-ready: issues-first tabs, centralized badge semantics (BADGE-001), and exactly one primary CTA depending on state.
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 (Filament v5 requires Livewire v4.0+)
|
||||
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`) + a new acknowledgement table
|
||||
**Testing**: Pest (PHPUnit)
|
||||
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: DB-only viewer renders quickly from stored JSON; fingerprint computation is linear in number of checks (typical report ≤ 50 checks)
|
||||
**Constraints**:
|
||||
- Viewer + Verify step are DB-only at render time (no outbound HTTP / Graph / job dispatch).
|
||||
- All mutations require server-side authorization (RBAC-UX) and explicit confirmation.
|
||||
- Status-like UI must use centralized badge semantics (BADGE-001).
|
||||
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding and provider workflows
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first, snapshots-second: PASS (report/ack UX; no inventory semantics changed).
|
||||
- Read/write separation: PASS (viewer remains read-only; acknowledgements are explicit mutations with confirmation + audit + tests).
|
||||
- Graph contract path: PASS (viewer is DB-only; no new Graph calls added by this feature).
|
||||
- Deterministic capabilities: PASS (capabilities remain centrally registered; no raw strings).
|
||||
- RBAC-UX: PASS (non-member access is 404; member missing capability is 403; server-side enforcement required).
|
||||
- Run observability: PASS (verification remains an `OperationRun`; dedupe while active is unchanged).
|
||||
- Data minimization: PASS (no secrets/tokens; audit payload excludes `ack_reason`).
|
||||
- Badge semantics (BADGE-001): PASS (no new status values; existing verification badge domains remain canonical).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/075-verification-v1-5/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
resources/
|
||||
routes/
|
||||
tests/
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web app with Filament v5 panel. This feature extends verification report writer/viewer, adds an acknowledgement persistence model + migration, and refactors the Verify step UI in Filament.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: [research.md](./research.md)
|
||||
|
||||
Goals covered:
|
||||
- Confirm canonical storage approach for report metadata (keep report in `operation_runs.context`).
|
||||
- Define deterministic fingerprint algorithm and previous report resolution rules.
|
||||
- Define acknowledgement persistence strategy and capability naming reconciliation.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- [data-model.md](./data-model.md)
|
||||
- [contracts/](./contracts/)
|
||||
- [quickstart.md](./quickstart.md)
|
||||
|
||||
Design focus:
|
||||
- Report metadata: add `fingerprint` and `previous_report_id` inside the report JSON.
|
||||
- Previous report resolution: match identity exactly (type/flow + tenant + workspace + provider connection), with `NULL` connection matching only `NULL`.
|
||||
- Acknowledgements: first-class DB table keyed by `(operation_run_id, check_key)`; immutable in v1.5.
|
||||
- Filament UX: issues-first tabs and “one primary CTA” rule; acknowledgements via `Action::make(...)->action(...)` + `->requiresConfirmation()`.
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Data
|
||||
- Migration: create `verification_check_acknowledgements` table with unique `(operation_run_id, check_key)`.
|
||||
- Model: `VerificationCheckAcknowledgement` with tenant/workspace scoping.
|
||||
|
||||
### Report writer / viewer
|
||||
- Extend the report writer to compute and store `fingerprint` and `previous_report_id` (report_id is the run id).
|
||||
- Extend the DB-only viewer to load previous report (when present) and compute the “changed/no-change” indicator.
|
||||
- Ensure the viewer consumes acknowledgements (DB lookup) and groups “Acknowledged issues” separately.
|
||||
|
||||
### Authorization + audit
|
||||
- Capability: add `tenant_verification.acknowledge` to the canonical capability registry and map to roles.
|
||||
- Server-side auth: non-members 404; members without capability 403 for acknowledgement.
|
||||
- Audit: add a new stable action ID (e.g. `verification.check_acknowledged`) with minimal metadata and no `ack_reason`.
|
||||
|
||||
### Filament UX
|
||||
- Verify step: implement the issues-first layout and strict “exactly one primary CTA” rule.
|
||||
- Actions: acknowledgement requires confirmation; navigation-only links remain links-only.
|
||||
- BADGE-001: continue to use centralized badge domains for statuses and summary.
|
||||
|
||||
### Tests (Pest)
|
||||
- Fingerprint determinism: same normalized inputs → same hash; severity-only differences → different hash.
|
||||
- Previous report linking: identity match includes provider connection (`NULL` only matches `NULL`).
|
||||
- RBAC-UX: non-member gets 404; member without capability gets 403 on acknowledgement.
|
||||
- Audit: acknowledgement emits correct action id + minimal metadata (assert `ack_reason` absent).
|
||||
- Viewer DB-only: no outbound HTTP during render/hydration.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. Design keeps report viewing DB-only, introduces a single tenant-scoped mutation with confirmation + audit, preserves RBAC-UX semantics, and maintains BADGE-001 centralized badge rendering.
|
||||
47
specs/075-verification-v1-5/quickstart.md
Normal file
47
specs/075-verification-v1-5/quickstart.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Quickstart: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
This quickstart is for developers implementing and validating Spec 075.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker + Sail
|
||||
- A seeded workspace + tenant and a user that can access the tenant plane (`/admin/t/{tenant}`)
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Run verification (expected UX)
|
||||
|
||||
After implementation, the Verify surface should behave like:
|
||||
|
||||
1. Navigate to the tenant-scoped Verify step (onboarding or equivalent).
|
||||
2. If no active run exists, the single primary CTA is **Start verification**.
|
||||
3. If a run is active, the single primary CTA is **Refresh results**.
|
||||
4. Results default to the **Issues** tab with blockers/failures/warnings ordered first.
|
||||
|
||||
## Acknowledge a check (expected UX)
|
||||
|
||||
After implementation:
|
||||
|
||||
1. On a `fail` or `warn` check card, click **Acknowledge**.
|
||||
2. Confirmation modal appears (required).
|
||||
3. Submit a short reason (≤ 160 chars) and optional expiry (informational only).
|
||||
4. The acknowledgement displays (who/when/reason) and the issue moves into the “Acknowledged issues” group.
|
||||
|
||||
## Authorization expectations
|
||||
|
||||
- Non-members attempting to access tenant-scoped verification pages: deny-as-not-found (404).
|
||||
- Tenant members without `tenant_verification.acknowledge`: acknowledgement attempts fail with 403.
|
||||
|
||||
## Run the focused test suite
|
||||
|
||||
Once tests are implemented:
|
||||
|
||||
- Run only the spec-related tests: `vendor/bin/sail artisan test --compact --filter=Verification` (or point at the specific test file(s)).
|
||||
|
||||
## Formatting
|
||||
|
||||
- Format only changed files before finalizing: `vendor/bin/sail bin pint --dirty`
|
||||
119
specs/075-verification-v1-5/research.md
Normal file
119
specs/075-verification-v1-5/research.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Research: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Date**: 2026-02-05
|
||||
**Phase**: Phase 0 (Foundational Research)
|
||||
**Status**: Complete
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-075-001 — Canonical storage for report + metadata
|
||||
|
||||
**Decision**: Store the verification report (including `fingerprint` and `previous_report_id`) inside `operation_runs.context.verification_report` (JSONB), consistent with 074.
|
||||
|
||||
**Rationale**:
|
||||
- Viewer surfaces must be DB-only at render time (constitution: Operations / Run Observability Standard).
|
||||
- `OperationRun` is already the canonical operational record and stable viewer entry point.
|
||||
- Adds supportability metadata without introducing a new top-level report table.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Dedicated `verification_reports` table: rejected for v1.5 to avoid new query/index surfaces; revisit if we need global querying across reports.
|
||||
|
||||
---
|
||||
|
||||
### D-075-002 — Report identity + “previous report” resolution
|
||||
|
||||
**Decision**: Resolve `previous_report_id` by querying the most recent earlier `OperationRun` whose **run identity** matches exactly (flow/type, tenant, workspace, provider connection where applicable).
|
||||
|
||||
**Rationale**:
|
||||
- The existing `OperationRunService::ensureRunWithIdentity()` + `run_identity_hash` already defines the dedupe boundary.
|
||||
- Matches the spec’s clarified rule: `provider_connection_id` must match exactly; `NULL` only matches `NULL`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Match previous runs by only `tenant_id + workspace_id + type` and then filter in PHP: rejected due to ambiguity and risk of cross-connection mixing.
|
||||
|
||||
---
|
||||
|
||||
### D-075-003 — Report ID semantics
|
||||
|
||||
**Decision**: Treat the `OperationRun` ID as the report identifier in UX and contracts (`report_id == operation_run_id`).
|
||||
|
||||
**Rationale**:
|
||||
- The report is attached to the run; the run is the stable, tenant-scoped canonical record.
|
||||
- Avoids a second identifier for the same “verification execution artifact”.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Generate a separate report UUID inside the JSON: rejected as it adds indirection without benefits in v1.5.
|
||||
|
||||
---
|
||||
|
||||
### D-075-004 — Fingerprint algorithm
|
||||
|
||||
**Decision**: Use SHA-256 over a deterministic normalization of check outcomes:
|
||||
- flatten checks
|
||||
- sort by `check.key`
|
||||
- contribute `key|status|blocking|reason_code|severity` where `severity` is always present (missing → empty)
|
||||
|
||||
Store as lowercase hex.
|
||||
|
||||
**Rationale**:
|
||||
- Deterministic across environments.
|
||||
- Treats severity-only changes as meaningful (per clarified requirement).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hash the full report JSON: rejected (unstable ordering, non-semantic fields like timestamps).
|
||||
|
||||
---
|
||||
|
||||
### D-075-005 — Per-check acknowledgements persistence
|
||||
|
||||
**Decision**: Create a first-class table `verification_check_acknowledgements` keyed by `(operation_run_id, check_key)` with a unique constraint.
|
||||
|
||||
**Rationale**:
|
||||
- Acknowledgements are governance metadata and must be queryable and auditable.
|
||||
- Unique per report/check is enforced by the DB.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store acknowledgements inside `operation_runs.context`: rejected as it complicates update semantics and auditability, and risks “report mutation” appearing like a changed verification outcome.
|
||||
|
||||
---
|
||||
|
||||
### D-075-006 — Capability naming reconciliation
|
||||
|
||||
**Decision**: Introduce a dedicated canonical capability `tenant_verification.acknowledge` in the capability registry and map it in the role → capability map.
|
||||
|
||||
**Rationale**:
|
||||
- Keeps the feature spec requirement literal and avoids overloading “findings” semantics.
|
||||
- Preserves the constitution rule that capabilities are centrally registered (no raw strings).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse existing `tenant_findings.acknowledge`: rejected because this feature is specifically verification-report scoped, and we want the permission surface to remain explicit.
|
||||
|
||||
---
|
||||
|
||||
### D-075-007 — Audit action identifier + payload minimization
|
||||
|
||||
**Decision**: Add a stable audit action ID for acknowledgements (e.g. `verification.check_acknowledged`) and emit it on successful acknowledgement. Audit metadata is minimal and MUST NOT include `ack_reason`.
|
||||
|
||||
**Rationale**:
|
||||
- Acknowledgement is a write mutation; constitution requires audit logging.
|
||||
- Spec explicitly excludes `ack_reason` from audit payload; it remains only in the acknowledgement record.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse `verification.completed`: rejected because it conflates verification execution with governance mutation.
|
||||
|
||||
---
|
||||
|
||||
### D-075-008 — Filament UI implementation constraints
|
||||
|
||||
**Decision**: Implement the “Verify step” UX changes in Filament v5 (Livewire v4) using:
|
||||
- DB-only viewer helper (no external calls)
|
||||
- centralized badge domains (BADGE-001)
|
||||
- mutation via Filament `Action::make(...)->action(...)` with `->requiresConfirmation()`
|
||||
|
||||
**Rationale**:
|
||||
- Aligns with Filament v5 patterns and constitution rules.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Publish/override Filament internal views: rejected; prefer render hooks + CSS hooks as needed.
|
||||
225
specs/075-verification-v1-5/spec.md
Normal file
225
specs/075-verification-v1-5/spec.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Feature Specification: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
|
||||
|
||||
**Feature Branch**: `075-verification-v1_5`
|
||||
**Created**: 2026-02-05
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Extend verification checklist framework with report fingerprint + previous report change indicator, per-check acknowledgements with audit/confirmation, and issues-first operator-ready verify-step UX."
|
||||
|
||||
## Goal
|
||||
|
||||
V1.5 extends the V1 verification checklist framework with two enterprise-critical additions while keeping scope intentionally small:
|
||||
|
||||
1) **Supportability / determinism**: show whether results changed since the previous verification for the same identity.
|
||||
2) **Governance**: allow explicit, auditable acknowledgement of known issues per failing check.
|
||||
3) **Enterprise UX completeness**: make the Verify step operator-ready (issues-first, one clear primary action, technical details secondary).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-05
|
||||
|
||||
- Q: How is “block” represented on checks? → A: No new status; a “Blocker” is `status=fail` with `blocking=true`.
|
||||
- Q: Should `severity` be part of the fingerprint? → A: Yes; include `severity` always (normalize missing to empty) to keep hashing deterministic and to treat severity-only changes as “Changed”.
|
||||
- Q: Should `ack_reason` be included in the audit event payload? → A: No; keep audit metadata minimal and store the reason only in the acknowledgement record.
|
||||
- Q: How should `provider_connection_id` be treated when resolving `previous_report_id`? → A: Match exactly; `NULL` only matches `NULL` (no cross-connection mixing).
|
||||
- **Report shape (canonical, inherited from 074)**: Persist reports as the existing V1 JSON shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`). V1.5 adds `fingerprint` + `previous_report_id` at the top level. No `sections[]` array is stored.
|
||||
- **Idempotency (inherited)**: Deduplication applies only while a run is active (`queued` / `running`). Once `completed` / `failed`, starting verification creates a new run.
|
||||
- **Viewing (inherited)**: Viewing is DB-only; rendering MUST NOT perform external calls.
|
||||
- **Evidence (inherited)**: Evidence is limited to safe pointers only; no secrets (no tokens/claims/headers/raw payloads).
|
||||
- **Next steps (inherited)**: Navigation-only links (no server-side “fix it” actions from the viewer).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Operator can tell “nothing changed” (Priority: P1)
|
||||
|
||||
As an operator, I can immediately see whether the current verification findings are unchanged compared to the previous verification for the same identity, so I can avoid unnecessary re-diagnosis.
|
||||
|
||||
**Why this priority**: This is the fastest path to supportability: it reduces repeated analysis and makes troubleshooting deterministic.
|
||||
|
||||
**Independent Test**: Create two reports for the same identity with identical normalized check outcomes; confirm the viewer indicates “No changes since previous verification”.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a report with a previous report available, **When** I open the viewer, **Then** I see a clear indicator “Changed” or “No changes”.
|
||||
2. **Given** the current report has the same fingerprint as the previous report, **When** I open the viewer, **Then** I see “No changes since previous verification”.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Owner/Manager can acknowledge a known issue (Priority: P1)
|
||||
|
||||
As an owner/manager, I can acknowledge a failing/warning/blocking check with a short reason (and optionally an expiry) so the team can see that the risk is known, evaluated, and accepted.
|
||||
|
||||
**Why this priority**: Acknowledgements provide governance without masking risk; they improve shared context and auditability.
|
||||
|
||||
**Independent Test**: With and without the acknowledgement capability, attempt to acknowledge a failing check; assert correct authorization (403) and that an audit event is recorded for the successful path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a check in status `fail` / `warn` (including failing blockers where `blocking=true`), **When** I acknowledge it with a reason, **Then** the UI shows who acknowledged it, when, and the reason.
|
||||
2. **Given** I do not have the acknowledgement capability, **When** I attempt to acknowledge a check, **Then** the server returns 403 and the UI does not offer the acknowledgement action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Verify step is operator-ready (issues-first) (Priority: P1)
|
||||
|
||||
As a workspace member, I see issues-first results with clear next steps and exactly one primary action (start or refresh), so I can remediate quickly without hunting through technical details.
|
||||
|
||||
**Why this priority**: The Verify step is a high-frequency operator surface; clarity and deterministic states reduce time-to-resolution.
|
||||
|
||||
**Independent Test**: Seed a report with blockers and a running state; confirm the default tab and “one primary CTA” rule is enforced in both completed and running scenarios.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a report with blockers, **When** I open the Verify step/viewer, **Then** the Issues tab is the default and blockers are at the top.
|
||||
2. **Given** a run is active, **When** I open the Verify step/viewer, **Then** the primary action is “Refresh results” and technical links are secondary.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- No previous report exists for an identity → no “changed/no-change” indicator is shown.
|
||||
- Run is active but no report is available yet → UI shows a clear “running, results will appear” explanation (no empty states without guidance).
|
||||
- Partial report while running → partial results render with a “Partial results” label.
|
||||
- Unknown check keys or reason codes → UI degrades gracefully, showing status and message without breaking.
|
||||
- Acknowledgement attempted for non-acknowledgeable status (e.g., `pass`) → request is rejected and UI does not offer it.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Diff/compare UI between reports
|
||||
- Server-side fixes initiated from the viewer
|
||||
- Undo / unacknowledge acknowledgements (V1.5 acknowledgements are immutable per report)
|
||||
- Complex staleness/TTL semantics (fresh/stale/expired)
|
||||
- Global dashboards / cross-tenant reporting
|
||||
- Export features (PDF/JSON) as a product feature
|
||||
- Live polling (V1.5 uses manual refresh)
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature adds new tenant-scoped mutations (acknowledgements) and new report metadata. It MUST include explicit confirmation, audit logging for mutations, tenant isolation, and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Tenant-scoped routes MUST preserve deny-as-not-found (404) for non-members, and use 403 for members missing a capability. UI visibility is not authorization; server-side enforcement is required.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Status-like badges MUST use centralized mapping semantics; no ad-hoc UI mappings.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-075-001 — Report fingerprint**: Each verification report MUST store a deterministic `fingerprint` derived from normalized check outcomes.
|
||||
|
||||
**Normalization rule (deterministic):**
|
||||
- Flatten all check results across `report.checks[]`
|
||||
- Sort by stable `check.key`
|
||||
- For each check, contribute a stable string using: `key | status | blocking | reason_code | severity`
|
||||
- `severity` MUST be included always; if the source report omits it, normalize to an empty string
|
||||
- The fingerprint MUST be a stable cryptographic hash of the joined contributions, stored as a fixed-length lowercase hex string.
|
||||
|
||||
- **FR-075-002 — Previous report link**: Each report MUST store `previous_report_id` (nullable) that points to the most recent earlier report for the same **verification identity**.
|
||||
|
||||
**Identity match** MUST include:
|
||||
- flow
|
||||
- workspace
|
||||
- tenant
|
||||
- provider connection (`provider_connection_id`) matched exactly; `NULL` only matches `NULL`
|
||||
|
||||
- **FR-075-003 — Change indicator**: When a previous report exists, the viewer MUST show:
|
||||
- “No changes since previous verification” if `fingerprint` matches
|
||||
- “Changed since previous verification” otherwise
|
||||
|
||||
- **FR-075-004 — Per-check acknowledgements (first-class)**: The system MUST allow acknowledging checks with status `fail` / `warn`.
|
||||
|
||||
An acknowledgement MUST record:
|
||||
- reason (max 160 characters)
|
||||
- acknowledged timestamp
|
||||
- acknowledged-by user
|
||||
- optional expiry timestamp
|
||||
|
||||
Acknowledgements MUST be unique per (report, check key). Expiry, when provided, is informational only in V1.5 and MUST NOT introduce automatic staleness/TTL behavior.
|
||||
|
||||
- **FR-075-005 — Acknowledgement does not change outcomes**: Acknowledging MUST NOT change:
|
||||
- the check status
|
||||
- the report summary status/outcome
|
||||
- the run outcome
|
||||
|
||||
- **FR-075-006 — Acknowledgement allowed conditions**: Acknowledgement MUST only be possible for checks whose status is in `{fail, warn}`. It MUST NOT be available for passing/green checks.
|
||||
|
||||
A check is considered a **Blocker** when `status=fail` and `blocking=true`; blockers are acknowledgeable under the same `{fail, warn}` rule (no separate `block` status exists).
|
||||
|
||||
- **FR-075-007 — Acknowledgement authorization (capability-first)**: Acknowledgement MUST require the capability `tenant_verification.acknowledge` as defined in the canonical capability registry.
|
||||
|
||||
RBAC UX semantics:
|
||||
- non-member / not entitled to tenant scope → 404
|
||||
- member without acknowledgement capability → 403
|
||||
- members with tenant scope but without acknowledgement capability can still view reports (view remains read-only)
|
||||
|
||||
- **FR-075-007A — Viewing authorization semantics preserved (inherited)**: Viewing tenant-scoped verification pages (Verify step + report viewer) MUST preserve V1 semantics:
|
||||
- non-member / not entitled to tenant scope → 404
|
||||
- member with tenant scope → can view
|
||||
- capability checks apply to mutations only (start verification, acknowledgement)
|
||||
|
||||
- **FR-075-008 — Confirmation + audit required**: Acknowledgement is a mutation and MUST require explicit user confirmation and MUST emit an audit event.
|
||||
|
||||
- **FR-075-009 — Audit event metadata (minimal)**: The audit event for acknowledgement MUST include minimally:
|
||||
- workspace, tenant, run, report, flow
|
||||
- check key and reason code
|
||||
- acknowledged-by user
|
||||
|
||||
It MUST NOT include `ack_reason`, secrets, tokens, or raw payloads.
|
||||
|
||||
- **FR-075-010 — DB-only viewing guard (inherited)**: Rendering the viewer and the Verify step MUST NOT trigger external calls.
|
||||
|
||||
- **FR-075-011 — Centralized badge semantics (BADGE-001)**: All check-status badges and summary-status badges used by V1.5 MUST use the centralized badge mapping registry.
|
||||
|
||||
- **FR-075-012 — Verify step enterprise UX (normative)**: The Verify step/viewer MUST follow an issues-first layout and deterministic UI states:
|
||||
|
||||
**Structure**
|
||||
- Always-visible summary card
|
||||
- Tabs: Issues (default), Passed, Technical details
|
||||
|
||||
**DB-only hint**
|
||||
- The summary surface MUST include a clear hint that viewing is read-only and performs no external calls.
|
||||
|
||||
**Primary action rule (strict)**
|
||||
- Exactly one primary call-to-action is shown at any time
|
||||
- “Start verification” and “Refresh results” MUST NOT both be primary simultaneously
|
||||
|
||||
**Issues tab ordering**
|
||||
1) Blockers (not acknowledged)
|
||||
2) Failures (not acknowledged)
|
||||
3) Warnings (not acknowledged)
|
||||
4) Acknowledged issues (collapsed group)
|
||||
|
||||
**Next steps rendering**
|
||||
- Max 2 navigation-only links per issue card
|
||||
- “Open run details” MUST appear only in Technical details (not in issue cards)
|
||||
|
||||
**Technical details**
|
||||
- Secondary surface that can show identifiers (run/report IDs), fingerprint, and previous report link
|
||||
- No raw payloads/tokens/full error bodies
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Verification Identity**: The stable identifiers that define “what is being verified” (flow, workspace, tenant, and optional provider connection).
|
||||
- **Verification Report**: A structured record of verification outcomes for a run.
|
||||
- **Report Fingerprint**: A deterministic hash representing normalized check outcomes.
|
||||
- **Previous Report**: The immediately preceding report for the same identity.
|
||||
- **Check Acknowledgement**: A governance record that an issue is known/accepted (who/when/reason/optional expiry) without altering the check outcome.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- A verification run/report concept already exists from V1.
|
||||
- The system has an audit log mechanism capable of recording acknowledgement actions.
|
||||
- Manual refresh is acceptable (no polling required).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Spec 074 (Verification Checklist Framework V1)
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-075-001 (Supportability)**: With a previous report present, operators can determine “changed vs no changes” within 10 seconds in 95% of tested sessions.
|
||||
- **SC-075-002 (Governance)**: 100% of successful acknowledgements create an audit log record with minimal metadata and no sensitive content.
|
||||
- **SC-075-003 (UX determinism)**: The Verify step renders exactly one primary CTA in all tested UI states (not started, running with/without report, completed).
|
||||
- **SC-075-004 (Authorization correctness)**: Non-members receive 404 for tenant-scoped access routes in 100% of tests; members without acknowledgement capability receive 403 for acknowledgement attempts in 100% of tests.
|
||||
- **SC-075-005 (No greenwashing)**: Acknowledging an issue never changes check status or the report summary in any tested scenario.
|
||||
|
||||
```
|
||||
172
specs/075-verification-v1-5/tasks.md
Normal file
172
specs/075-verification-v1-5/tasks.md
Normal file
@ -0,0 +1,172 @@
|
||||
---
|
||||
|
||||
description: "Task breakdown for Spec 075 (Verification Checklist Framework V1.5)"
|
||||
---
|
||||
|
||||
# Tasks: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Input**: Design documents from `/specs/075-verification-v1-5/`
|
||||
|
||||
**Tests**: REQUIRED (Pest)
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Align feature artifacts with the existing 074 verification implementation (report shape, DB-only viewing constraints).
|
||||
|
||||
- [X] T001 Reconcile v1.5 report contract to match the V1 report shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`) + v1.5 fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json
|
||||
- [X] T002 [P] Confirm viewer surfaces are DB-only using existing guard helpers in tests/Support/AssertsNoOutboundHttp.php (helper availability + correct usage patterns)
|
||||
- [X] T003 [P] Identify all report viewer templates to update: resources/views/filament/components/verification-report-viewer.blade.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required by all user stories (schema/sanitization, stable fingerprint, previous report resolution).
|
||||
|
||||
**⚠️ CRITICAL**: Complete this phase before implementing US1/US2/US3.
|
||||
|
||||
- [X] T004 Update report schema to allow v1.5 metadata fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSchema.php
|
||||
- [X] T005 Update report sanitizer to preserve v1.5 metadata fields (`fingerprint`, `previous_report_id`) and preserve empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSanitizer.php
|
||||
- [X] T006 [P] Add a deterministic fingerprint helper in app/Support/Verification/VerificationReportFingerprint.php (flatten `checks[]`; normalize missing `severity` to empty string, not `info`)
|
||||
- [X] T007 Add a previous-report resolver helper in app/Support/Verification/PreviousVerificationReportResolver.php
|
||||
- [X] T008 [P] Add or update verification badge mapping tests in tests/Feature/Badges/ to cover all v1.5-used status-like values (BADGE-001)
|
||||
|
||||
**Checkpoint**: Schema + sanitizer accept v1.5 fields; fingerprint + previous-report resolver are available for use.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Operator can tell “nothing changed” (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Persist a deterministic `fingerprint` + `previous_report_id` on each report, and show “Changed / No changes” when a previous report exists.
|
||||
|
||||
**Independent Test**: Create two completed verification runs for the same identity with identical normalized outcomes; confirm viewer indicates “No changes since previous verification”.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T009 [P] [US1] Add fingerprint determinism unit tests in tests/Feature/Verification/VerificationReportFingerprintTest.php (including missing severity → empty string, and severity-only changes → different hash)
|
||||
- [X] T010 [P] [US1] Add previous report identity matching tests (provider_connection_id exact match; NULL matches NULL) and a regression proving cross-connection runs don’t match when run_identity_hash includes provider_connection_id in tests/Feature/Verification/PreviousVerificationReportResolverTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Compute and persist report fingerprint in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/VerificationReportFingerprint.php)
|
||||
- [X] T012 [US1] Resolve and persist previous_report_id during write in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/PreviousVerificationReportResolver.php + run_identity_hash; verify all verification run start paths include provider_connection_id in identityInputs)
|
||||
- [X] T013 [P] [US1] Extend DB-only report viewer helper to expose v1.5 metadata in app/Filament/Support/VerificationReportViewer.php
|
||||
- [X] T014 [US1] Add change-indicator computation for viewer surfaces in app/Filament/Support/VerificationReportChangeIndicator.php
|
||||
|
||||
**Checkpoint**: Report JSON includes `fingerprint` + `previous_report_id`; viewer can derive Changed/No changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Owner/Manager can acknowledge a known issue (Priority: P1)
|
||||
|
||||
**Goal**: Acknowledge `fail` / `warn` checks per report with confirmation + audit, without changing check outcomes.
|
||||
|
||||
**Independent Test**: Attempt to acknowledge a failing check (a) as non-member → 404, (b) as member without capability → 403, (c) with capability → record created + audit logged.
|
||||
|
||||
### Tests for User Story 2 (write first)
|
||||
|
||||
- [X] T015 [P] [US2] Add acknowledgement authorization + audit tests in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php (404 non-member, 403 missing capability, persists optional expires_at; audit metadata includes check_key + reason_code and excludes ack_reason)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Create migration for verification_check_acknowledgements table (includes optional expires_at; informational only) in database/migrations/*_create_verification_check_acknowledgements_table.php
|
||||
- [X] T017 [P] [US2] Create model in app/Models/VerificationCheckAcknowledgement.php
|
||||
- [X] T018 [P] [US2] Create factory for acknowledgements in database/factories/VerificationCheckAcknowledgementFactory.php
|
||||
- [X] T019 [US2] Implement acknowledgement creation service in app/Services/Verification/VerificationCheckAcknowledgementService.php (server-side authorization via Gate/policy; validate status ∈ {fail,warn}; validate optional expires_at; enforce unique per (operation_run_id, check_key))
|
||||
- [X] T020 [P] [US2] Register capability constant tenant_verification.acknowledge in app/Support/Auth/Capabilities.php
|
||||
- [X] T021 [P] [US2] Map tenant_verification.acknowledge to tenant roles in app/Services/Auth/RoleCapabilityMap.php
|
||||
- [X] T022 [P] [US2] Add audit action id for acknowledgement in app/Support/Audit/AuditActionId.php (e.g. verification.check_acknowledged)
|
||||
- [X] T023 [US2] Emit audit event with minimal metadata via app/Services/Audit/WorkspaceAuditLogger.php from the acknowledgement path (MUST include: tenant_id, operation_run_id/report_id, flow, check_key, reason_code; MUST NOT include ack_reason)
|
||||
|
||||
**Checkpoint**: Acknowledgements are persisted, authorized, confirmed in UI (next story), and audited with minimized metadata.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Verify step is operator-ready (issues-first) (Priority: P1)
|
||||
|
||||
**Goal**: Issues-first view, centralized badge semantics (BADGE-001), DB-only hint, and exactly one primary CTA depending on state.
|
||||
|
||||
**Independent Test**: Seed a run with blockers while completed and while running; confirm Issues is default, ordering rules hold, and one-primary-CTA rule holds.
|
||||
|
||||
### Tests for User Story 3 (write first)
|
||||
|
||||
- [X] T024 [P] [US3] Add Verify-step CTA and ordering tests in tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
|
||||
- [X] T025 [P] [US3] Add DB-only render guard test coverage for Verify surfaces in tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Enforce “exactly one primary CTA” logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start vs refresh)
|
||||
- [X] T027 [US3] Refactor Verify-step report view to issues-first tabs + ordering + DB-only hint in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
- [X] T028 [US3] Add per-check acknowledgement action UI with confirmation in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Action::make(...)->action(...)->requiresConfirmation())
|
||||
- [X] T029 [US3] Wire acknowledgement UI to service + RBAC semantics (404 non-member, 403 missing capability; server-side enforcement required) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [X] T030 [US3] Update the Monitoring viewer to match v1.5 UX rules (issues-first tabs: Issues default, Passed, Technical details; ordering; next-steps max 2) in resources/views/filament/components/verification-report-viewer.blade.php
|
||||
- [X] T031 [P] [US3] Show change indicator + previous report link in technical details (no raw payloads) in resources/views/filament/components/verification-report-viewer.blade.php
|
||||
|
||||
**Checkpoint**: Verify UX is deterministic, issues-first, and operator-ready across onboarding and monitoring surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Hardening, formatting, and regression coverage.
|
||||
|
||||
- [X] T032 [P] Ensure acknowledgement does not mutate check status/summary in app/Support/Verification/VerificationReportWriter.php and cover with assertions in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php
|
||||
- [X] T033 [P] Add redaction regression checks for new v1.5 fields (fingerprint/previous_report_id) in tests/Feature/Verification/VerificationReportRedactionTest.php
|
||||
- [X] T034 [P] Run Pint on changed files via vendor/bin/sail bin pint --dirty
|
||||
- [X] T035 Run focused test suite via vendor/bin/sail artisan test --compact --filter=Verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: start immediately
|
||||
- **Foundational (Phase 2)**: blocks all user stories
|
||||
- **User Stories (Phase 3–5)**:
|
||||
- US1 depends on Phase 2
|
||||
- US2 depends on Phase 2
|
||||
- US3 depends on Phase 2 and benefits from US1 + US2 completion
|
||||
- **Polish (Phase 6)**: after US1–US3
|
||||
|
||||
### User Story Dependencies (Graph)
|
||||
|
||||
- **US1 (Fingerprint + previous report + changed indicator)** → enables technical details and “Changed/No changes” banner in US3
|
||||
- **US2 (Acknowledgements)** → enables “Acknowledged issues” grouping and action UX in US3
|
||||
- **US3 (Verify UX)** → integrates outputs of US1 + US2 into operator surface
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- Run in parallel:
|
||||
- T009 (fingerprint determinism tests) + T010 (previous resolver tests)
|
||||
- T013 (viewer helper exposure) can proceed while T011/T012 land
|
||||
|
||||
### US2
|
||||
|
||||
- Run in parallel:
|
||||
- T017 (model) + T018 (factory) + T020 (capability constant) + T021 (role mapping) + T022 (audit action id)
|
||||
|
||||
### US3
|
||||
|
||||
- Run in parallel:
|
||||
- T024 (UX tests) + T025 (DB-only tests)
|
||||
- T027 (onboarding blade refactor) + T030 (monitoring viewer refactor)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1)
|
||||
|
||||
1. Phase 1 → Phase 2
|
||||
2. Implement US1 (Phase 3)
|
||||
3. Validate: run T035 and confirm “No changes since previous verification” path
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. US1 (supportability) → US2 (governance) → US3 (operator UX)
|
||||
2. After each story, run story-specific tests plus `vendor/bin/sail artisan test --compact --filter=Verification`
|
||||
@ -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).
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Workspace-first Navigation & Monitoring Hub
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-06
|
||||
**Feature**: [specs/077-workspace-nav-monitoring-hub/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
|
||||
|
||||
- Validation pass on first iteration.
|
||||
- URLs are treated as product behavior (not implementation details).
|
||||
69
specs/077-workspace-nav-monitoring-hub/contracts/routes.md
Normal file
69
specs/077-workspace-nav-monitoring-hub/contracts/routes.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Contracts — Routes & Semantics (077)
|
||||
|
||||
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](../spec.md)
|
||||
|
||||
This feature is an admin UI/navigation refactor. Contracts are expressed as web route semantics + access rules.
|
||||
|
||||
## Canonical routes
|
||||
|
||||
### Workspace context
|
||||
|
||||
- `GET /admin/choose-workspace`
|
||||
- Purpose: select active workspace context
|
||||
- Access: authenticated user
|
||||
- Visibility: shows only workspaces where the user is a member
|
||||
|
||||
- `POST /admin/switch-workspace`
|
||||
- Purpose: update workspace context
|
||||
- Access: authenticated user
|
||||
- Security:
|
||||
- If user is not a member of the selected workspace → 404 (deny-as-not-found)
|
||||
|
||||
### Workspace management (CRUD)
|
||||
|
||||
- `GET /admin/workspaces`
|
||||
- `GET /admin/workspaces/{workspace}`
|
||||
- `GET /admin/workspaces/{workspace}/edit`
|
||||
- `GET /admin/workspaces/create`
|
||||
|
||||
Contract semantics:
|
||||
|
||||
- Workspace context is optional on `/admin/workspaces` (Global Mode).
|
||||
- Index lists only workspaces the user is a member of.
|
||||
- If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found)
|
||||
- Workspace creation is self-serve for authenticated users (policy-driven).
|
||||
- If user is a member but lacks the required capability for a protected action/screen (edit/membership management) → 403
|
||||
- If user is authorized → normal Filament behavior
|
||||
|
||||
### Monitoring hub — Operations
|
||||
|
||||
- `GET /admin/operations`
|
||||
- Canonical operations index (tenantless URL)
|
||||
- Behavior:
|
||||
- If tenant context is active: default filter state = current tenant (removable)
|
||||
- If tenant context is not active: workspace-wide list
|
||||
|
||||
- `GET /admin/operations/{run}`
|
||||
- Canonical run deep link
|
||||
- Security:
|
||||
- If run belongs to a workspace the user is not a member of → 404
|
||||
|
||||
### Monitoring hub — Reserved surfaces (placeholders)
|
||||
|
||||
- `GET /admin/alerts`
|
||||
- Reserved placeholder page
|
||||
- Access: workspace members (workspace context required)
|
||||
|
||||
- `GET /admin/audit-log`
|
||||
- Reserved placeholder page
|
||||
- Access: workspace members (workspace context required)
|
||||
|
||||
## Status code rules (summary)
|
||||
|
||||
- Non-member / not entitled to the workspace scope → 404
|
||||
- Member but missing capability (workspace-scoped protected actions) → 403
|
||||
|
||||
## Non-leakage requirements
|
||||
|
||||
- Global search must not list inaccessible workspaces/tenants/runs.
|
||||
- Navigation labels and groups must not imply the existence of admin-only surfaces.
|
||||
66
specs/077-workspace-nav-monitoring-hub/data-model.md
Normal file
66
specs/077-workspace-nav-monitoring-hub/data-model.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Data Model — Workspace-first Navigation & Monitoring Hub (077)
|
||||
|
||||
**Date**: 2026-02-06
|
||||
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||
|
||||
This feature is primarily information architecture + context enforcement. No new tables are required; the design depends on existing entities and their relationships.
|
||||
|
||||
## Entities
|
||||
|
||||
### Workspace
|
||||
|
||||
Represents a portfolio / customer container (primary context).
|
||||
|
||||
- Key fields (existing, relevant):
|
||||
- `id`
|
||||
- `name`
|
||||
- `slug` (optional)
|
||||
- `archived_at` (nullable)
|
||||
|
||||
### WorkspaceMembership
|
||||
|
||||
Entitlement relationship between a user and a workspace.
|
||||
|
||||
- Key fields (existing, relevant):
|
||||
- `workspace_id`
|
||||
- `user_id`
|
||||
- `role` (e.g. owner/operator/etc; actual role semantics are managed by the capability resolver)
|
||||
|
||||
### Tenant (Managed Tenant)
|
||||
|
||||
Represents a Microsoft/Intune tenant belonging to a workspace (secondary context via Filament tenancy).
|
||||
|
||||
- Key fields (existing, relevant):
|
||||
- `id`
|
||||
- `workspace_id` (foreign key to Workspace)
|
||||
- `external_id` (used in Filament tenancy route `/admin/t/{tenant}`)
|
||||
- `status` (e.g., active)
|
||||
|
||||
### OperationRun
|
||||
|
||||
Canonical monitoring record (workspace-level entity; may optionally be linked to a tenant).
|
||||
|
||||
- Key fields (existing, relevant):
|
||||
- `id`
|
||||
- `workspace_id` (required for access control)
|
||||
- `tenant_id` (nullable; used for default filtering and “recent operations”)
|
||||
- `type`, `status`, `outcome`
|
||||
- timestamps (created/started/completed)
|
||||
- `context` (JSON)
|
||||
|
||||
## Relationships
|
||||
|
||||
- Workspace has many WorkspaceMemberships.
|
||||
- Workspace has many Tenants.
|
||||
- Workspace has many OperationRuns.
|
||||
- Tenant belongs to Workspace.
|
||||
- OperationRun belongs to Workspace.
|
||||
- OperationRun optionally belongs to Tenant.
|
||||
|
||||
## Invariants / Rules enforced by this feature
|
||||
|
||||
- Workspace context (`current_workspace_id`) is required for workspace-scoped navigation and access control.
|
||||
- Tenant context must be consistent with workspace context:
|
||||
- If tenant is not in current workspace, tenant context is cleared (continue tenantless).
|
||||
- OperationRun access is controlled by membership in the run’s `workspace_id`.
|
||||
|
||||
215
specs/077-workspace-nav-monitoring-hub/plan.md
Normal file
215
specs/077-workspace-nav-monitoring-hub/plan.md
Normal file
@ -0,0 +1,215 @@
|
||||
# Implementation Plan: Workspace-first Navigation & Monitoring Hub
|
||||
|
||||
**Branch**: `077-workspace-nav-monitoring-hub` | **Date**: 2026-02-06 | **Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||
**Input**: Feature specification from [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Resolve workspace navigation ambiguity and formalize a workspace-first context model:
|
||||
|
||||
- Unambiguous labels: **Switch workspace** (`/admin/choose-workspace`) vs **Manage workspaces** (`/admin/workspaces`).
|
||||
- Monitoring → **Operations** remains canonical and tenantless (`/admin/operations`, `/admin/operations/{run}`).
|
||||
- Tenant context influences Operations only via **server-side default filter state** (removable), never via routing.
|
||||
- Strict non-leaking security semantics:
|
||||
- Non-member workspace scope → 404 (deny-as-not-found)
|
||||
- Workspace member missing capability (protected actions/screens) → 403
|
||||
- Accessing a workspace record outside membership → 404 (deny-as-not-found)
|
||||
|
||||
Supporting artifacts:
|
||||
|
||||
- [research.md](research.md)
|
||||
- [data-model.md](data-model.md)
|
||||
- [contracts/routes.md](contracts/routes.md)
|
||||
- [quickstart.md](quickstart.md)
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Web (Filament admin panels)
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**: Operations pages remain DB-only at render; list/detail stay fast on large run tables (pagination + indexed filters)
|
||||
**Constraints**: Filament-native patterns only; canonical URLs must not depend on tenant context; strict 404/403 non-leakage semantics
|
||||
**Scale/Scope**: Multi-workspace MSP use; many tenants and many operation runs
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: N/A (no inventory semantics changes)
|
||||
- Read/write separation: PASS (no write operations introduced)
|
||||
- Graph contract path: N/A (no Graph calls)
|
||||
- Deterministic capabilities: PASS (capability gating uses existing resolver/registry patterns)
|
||||
- RBAC-UX: PASS (explicit 404 vs 403 rules)
|
||||
- RBAC-UX destructive confirmation: N/A (no destructive actions introduced)
|
||||
- RBAC-UX global search: N/A (no new searchable resources; no changes to global search)
|
||||
- Tenant isolation: PASS (workspace membership is isolation boundary; tenant context auto-cleared when invalid)
|
||||
- Run observability: N/A (no new operations/jobs)
|
||||
- Automation: N/A
|
||||
- Data minimization: N/A
|
||||
- Badge semantics (BADGE-001): N/A
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/077-workspace-nav-monitoring-hub/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── routes.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── ChooseWorkspace.php
|
||||
│ └── Resources/
|
||||
│ ├── OperationRunResource.php
|
||||
│ └── OperationRunResource/
|
||||
│ └── Pages/
|
||||
│ └── ListOperationRuns.php
|
||||
├── Http/
|
||||
│ └── Middleware/
|
||||
│ └── EnsureWorkspaceSelected.php
|
||||
├── Providers/
|
||||
│ └── Filament/
|
||||
│ └── AdminPanelProvider.php
|
||||
└── Support/
|
||||
└── Middleware/
|
||||
└── EnsureFilamentTenantSelected.php
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
└── partials/
|
||||
└── workspace-switcher.blade.php
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
└── (new tests for navigation labels + 404/403 + operations default filter)
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith using Filament resources/pages and Laravel middleware.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations.
|
||||
|
||||
## Phase 0 — Outline & Research (complete)
|
||||
|
||||
All unknowns/decisions have been resolved and recorded:
|
||||
|
||||
- Repo reality + ambiguity sources + decisions D1–D4: [research.md](research.md)
|
||||
- No remaining NEEDS CLARIFICATION items in the spec.
|
||||
|
||||
## Phase 1 — Design & Contracts (complete)
|
||||
|
||||
- Data model: no new tables/columns required; behavior is implemented via middleware + Filament config: [data-model.md](data-model.md)
|
||||
- Route/security contracts: [contracts/routes.md](contracts/routes.md)
|
||||
- Manual validation steps + suggested test filters: [quickstart.md](quickstart.md)
|
||||
|
||||
## Phase 2 — Implementation Plan (ready for tasks)
|
||||
|
||||
### Step 1 — Navigation labels: “one label, one meaning”
|
||||
|
||||
- Update admin navigation to include:
|
||||
- **Switch workspace** (topbar context switcher) → `/admin/choose-workspace`
|
||||
- **Manage workspaces** (sidebar Settings) → `/admin/workspaces`
|
||||
- Remove/replace any navigation items labeled only “Workspaces”.
|
||||
|
||||
Implementation targets:
|
||||
|
||||
- Update [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) navigation builder:
|
||||
- Change the label from `Workspaces` to `Switch workspace` for the choose-workspace link.
|
||||
- Ensure this fallback navigation does not accidentally imply CRUD management.
|
||||
- Update [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php) nav item label for workspace CRUD to `Manage workspaces`.
|
||||
- Update [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php) text/links to consistently say “Switch workspace”.
|
||||
- Add reserved Monitoring navigation surfaces for **Alerts** and **Audit Log** as placeholder pages (non-functional “coming soon”) to satisfy FR-011.
|
||||
|
||||
### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
|
||||
|
||||
- `/admin/workspaces` stays tenantless and is **Global Mode** (workspace-optional).
|
||||
- Enforce strict non-leakage semantics:
|
||||
- Non-member attempting to access a workspace record → **404** (deny-as-not-found)
|
||||
- Member missing required capability for protected actions/screens → **403**
|
||||
|
||||
Implementation targets:
|
||||
|
||||
- Scope the Workspaces query (index) to only workspaces the user is a member of.
|
||||
- Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access).
|
||||
- Workspace creation is self-serve (policy-driven). Gate edit/membership-management behind canonical workspace capabilities (no raw strings).
|
||||
- Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based).
|
||||
|
||||
### Step 3 — Workspace selection redirect + return-to-intended
|
||||
|
||||
Requirement: visiting any workspace-scoped page without a selected workspace MUST redirect to `/admin/choose-workspace` and then return to the originally requested URL.
|
||||
|
||||
Implementation targets:
|
||||
|
||||
- Update [app/Http/Middleware/EnsureWorkspaceSelected.php](../../app/Http/Middleware/EnsureWorkspaceSelected.php):
|
||||
- When redirecting to `/admin/choose-workspace`, store the intended URL (path + query) in session.
|
||||
- Preserve the existing exemptions for auth routes and for `/admin/operations/{run}` and Livewire update referers.
|
||||
- Update both workspace-selection entrypoints to honor intended URLs:
|
||||
- [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
|
||||
- [app/Http/Controllers/SwitchWorkspaceController.php](../../app/Http/Controllers/SwitchWorkspaceController.php)
|
||||
- After setting the workspace, redirect to the stored intended URL (if present and safe), otherwise keep the existing behavior (onboarding / choose-tenant / tenant dashboard).
|
||||
|
||||
### Step 4 — Auto-clear invalid tenant context on workspace change
|
||||
|
||||
Requirement: if tenant context is active but does not belong to the current workspace, auto-clear tenant context and continue on tenantless workspace pages.
|
||||
|
||||
Implementation targets:
|
||||
|
||||
- In [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) (or a dedicated middleware used for tenantless pages):
|
||||
- Detect a persisted Filament tenant that does not match `WorkspaceContext::currentWorkspaceId()`.
|
||||
- Clear the persisted Filament tenant context (confirm the correct Filament v5 mechanism during implementation).
|
||||
|
||||
### Step 5 — Operations: move tenant scoping from query to removable default filter
|
||||
|
||||
Requirement: `/admin/operations` stays canonical; if tenant context is active, default to that tenant using server-side default filter state with a visible removable chip.
|
||||
|
||||
Implementation targets:
|
||||
|
||||
- Update [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php):
|
||||
- Remove tenant-context filtering from `getEloquentQuery()`.
|
||||
- Update [app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php](../../app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php):
|
||||
- Add a tenant filter (select) over available tenants in the current workspace.
|
||||
- Default the filter state from the current tenant context when valid.
|
||||
- Ensure the filter chip is visible and can be cleared to view workspace-wide operations.
|
||||
|
||||
### Step 6 — Tests (Pest) + formatting
|
||||
|
||||
Add/adjust tests to cover the strict semantics:
|
||||
|
||||
- Navigation labels: “Switch workspace” vs “Manage workspaces” (no ambiguous “Workspaces”).
|
||||
- `/admin/workspaces`:
|
||||
- non-member record access → 404
|
||||
- member missing capability for a protected action/screen → 403
|
||||
- EnsureWorkspaceSelected:
|
||||
- visiting `/admin/operations` without workspace → redirects to choose-workspace
|
||||
- after selecting workspace → returns to intended URL
|
||||
- Operations default filter:
|
||||
- with tenant context active → tenant filter default set
|
||||
- clearing filter → shows workspace-wide results
|
||||
|
||||
Tooling:
|
||||
|
||||
- Run `./vendor/bin/sail bin pint --dirty`.
|
||||
- Run focused tests via `./vendor/bin/sail artisan test --compact --filter=...`.
|
||||
50
specs/077-workspace-nav-monitoring-hub/quickstart.md
Normal file
50
specs/077-workspace-nav-monitoring-hub/quickstart.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Quickstart — Workspace-first Navigation & Monitoring Hub (077)
|
||||
|
||||
**Audience**: Devs and reviewers validating the feature on staging/local
|
||||
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `./vendor/bin/sail up -d`
|
||||
- Install dependencies if needed: `./vendor/bin/sail composer install` and `./vendor/bin/sail npm install`
|
||||
- Run migrations: `./vendor/bin/sail artisan migrate`
|
||||
|
||||
## Manual validation checklist
|
||||
|
||||
### Navigation separation
|
||||
|
||||
1. Open `/admin` and sign in.
|
||||
2. In the user menu, confirm there is an explicit entry labeled **"Switch workspace"** that navigates to `/admin/choose-workspace`.
|
||||
3. In the sidebar, confirm **"Manage workspaces"** exists only when authorized.
|
||||
4. Confirm there is no navigation item labeled simply **"Workspaces"** that ambiguously points to both concepts.
|
||||
|
||||
### Operations canonical + default tenant filter
|
||||
|
||||
1. Visit `/admin/operations` with no tenant context selected.
|
||||
- Expect: page loads and shows workspace-wide runs.
|
||||
2. Activate tenant context (`/admin/t/{tenant}`), then navigate to `/admin/operations`.
|
||||
- Expect: default tenant filter applied, visible filter chip, chip can be cleared.
|
||||
3. Visit a run deep link `/admin/operations/{run}` from both tenantless and tenant context.
|
||||
- Expect: same canonical page, no tenant-route dependency.
|
||||
|
||||
### Security semantics
|
||||
|
||||
- Non-member accessing operations for another workspace: expect **404**.
|
||||
- Workspace member but missing capability for a protected action/screen: expect **403**.
|
||||
- Accessing `/admin/workspaces` for a workspace you are not a member of: expect **404**.
|
||||
|
||||
## Test execution
|
||||
|
||||
Run focused tests:
|
||||
|
||||
- US1 (nav separation): `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`
|
||||
- US2 (canonical ops URLs): `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls`
|
||||
- US3 (non-leakage): `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
|
||||
|
||||
Run a targeted suite for the feature area:
|
||||
|
||||
- `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
|
||||
|
||||
Run formatting before finalizing:
|
||||
|
||||
- `./vendor/bin/sail pint --dirty`
|
||||
58
specs/077-workspace-nav-monitoring-hub/research.md
Normal file
58
specs/077-workspace-nav-monitoring-hub/research.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Research — Workspace-first Navigation & Monitoring Hub (077)
|
||||
|
||||
**Date**: 2026-02-06
|
||||
**Branch**: 077-workspace-nav-monitoring-hub
|
||||
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||
|
||||
## Repo Reality Check (what exists today)
|
||||
|
||||
- Admin panel exists at `/admin` via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
|
||||
- System panel exists at `/system` with a separate auth guard (`platform`) via [app/Providers/Filament/SystemPanelProvider.php](../../app/Providers/Filament/SystemPanelProvider.php).
|
||||
- Workspace context selection exists:
|
||||
- Page `/admin/choose-workspace` via [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
|
||||
- POST switch endpoint `/admin/switch-workspace` via [routes/web.php](../../routes/web.php)
|
||||
- Workspace switcher UI in the user menu via [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php)
|
||||
- Navigation ambiguity is currently real:
|
||||
- When no tenant is selected, navigation is replaced with a single item labeled **"Workspaces"** linking to the choose-workspace page via [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php).
|
||||
- Separately, the sidebar includes another **"Workspaces"** item linking to `/admin/workspaces` (workspace CRUD) via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
|
||||
- Operations is already canonical and tenantless:
|
||||
- Resource slug is `/admin/operations` via [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php).
|
||||
- Detail page is `/admin/operations/{record}`.
|
||||
|
||||
## Decisions (resolved)
|
||||
|
||||
### D1 — Manage workspaces stays on `/admin/workspaces` and follows workspace RBAC semantics (404 for non-members, 403 for missing capability)
|
||||
|
||||
- Decision: Treat `/admin/workspaces` as a **workspace-scoped** management surface in the tenant plane (`/admin`, Entra users):
|
||||
- Non-members (or out-of-scope workspace records) → **404** (deny-as-not-found)
|
||||
- Members missing required capabilities for protected actions/screens → **403**
|
||||
- Rationale: Aligns with the constitution RBAC-UX model (membership is the isolation boundary; capability denial is 403 after membership is established) while still preventing cross-workspace leakage.
|
||||
- Alternatives considered:
|
||||
- Move management into `/system` panel: rejected because this feature targets the tenant plane IA. (If workspace CRUD becomes platform-admin only later, that should be handled as a separate migration spec.)
|
||||
|
||||
### D2 — Tenant context influences Operations via server-side default filter state, not querystring
|
||||
|
||||
- Decision: Apply the tenant default filter server-side while keeping the canonical URL `/admin/operations` unchanged.
|
||||
- Rationale: Matches Spec 077 clarification (Q2=A). Prevents link-sharing surprises and keeps canonical monitoring routes stable.
|
||||
- Alternatives considered:
|
||||
- Querystring-based default filtering (e.g. `?tenant_id=`): rejected as it makes filtered URLs the de-facto navigation target.
|
||||
|
||||
### D3 — Missing workspace context redirects to `/admin/choose-workspace` and returns to the requested URL
|
||||
|
||||
- Decision: When a workspace-scoped page is visited without an active workspace selection, redirect to `/admin/choose-workspace` and then return.
|
||||
- Rationale: Matches Spec 077 clarification (Q3=A) and aligns with existing `/admin` root override behavior in [routes/web.php](../../routes/web.php).
|
||||
|
||||
### D4 — Invalid tenant context (tenant not in current workspace) is auto-cleared
|
||||
|
||||
- Decision: If tenant context is active but does not belong to the current workspace, clear tenant context and continue on workspace-level pages.
|
||||
- Rationale: Matches Spec 077 clarification (Q4=A). Reduces “ghost tenant” behavior after a workspace switch.
|
||||
- Alternatives considered:
|
||||
- Hard 404: rejected as too confusing during normal context switching.
|
||||
|
||||
## Key Implementation Implications (for planning)
|
||||
|
||||
- **Rename navigation labels** to satisfy “one label, one meaning”:
|
||||
- The “Workspaces” navigation item that points to the choose-workspace page must become **"Switch workspace"**.
|
||||
- The “Workspaces” navigation item that points to CRUD must become **"Manage workspaces"** and be capability-gated with workspace RBAC semantics (404 for non-members; 403 for missing capability).
|
||||
- **Operations filter chip/removal**: current behavior filters by `Tenant::current()` inside `OperationRunResource::getEloquentQuery()`, which is not user-removable. The plan should move this behavior into a table filter with default state.
|
||||
- **No render-time external calls**: monitoring pages must remain DB-only at render (already consistent with constitution).
|
||||
164
specs/077-workspace-nav-monitoring-hub/spec.md
Normal file
164
specs/077-workspace-nav-monitoring-hub/spec.md
Normal file
@ -0,0 +1,164 @@
|
||||
# Feature Specification: Workspace-first Navigation & Monitoring Hub
|
||||
|
||||
**Feature Branch**: `077-workspace-nav-monitoring-hub`
|
||||
**Created**: 2026-02-06
|
||||
**Status**: Implemented
|
||||
**Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-06
|
||||
|
||||
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). `/admin/workspaces` is **Global Mode** (workspace-optional). Index lists only the user’s workspaces; per-record access for non-members is 404 (deny-as-not-found); protected actions/screens return 403 when unauthorized.
|
||||
- Q: Should `/admin/workspaces` require an active `current_workspace_id`? → A: No. `/admin/workspaces` is **Global Mode** (workspace-optional). The index lists only workspaces the user is a member of; per-record access for non-members remains 404.
|
||||
- Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`.
|
||||
- Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection.
|
||||
- Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Switch workspace without ambiguity (Priority: P1)
|
||||
|
||||
As an operator/admin, I need to switch my active workspace (portfolio) using a clear, single-purpose entry point, so that I never confuse "switch workspace" with "manage workspaces".
|
||||
|
||||
**Why this priority**: Workspace context is foundational. If it’s confusing, every other module becomes harder to use and support.
|
||||
|
||||
**Independent Test**: A user can find "Switch workspace", select a workspace they are a member of, and the application context updates while workspace management remains separate.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am signed in and belong to multiple workspaces, **When** I choose "Switch workspace", **Then** I see only workspaces I am a member of and can select one.
|
||||
2. **Given** I can manage workspaces, **When** I open "Manage workspaces", **Then** I can access workspace CRUD screens and breadcrumbs stay within the management area.
|
||||
3. **Given** I cannot manage workspaces, **When** I look at navigation, **Then** I do not see "Manage workspaces".
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Use Monitoring hub from canonical links (Priority: P2)
|
||||
|
||||
As an operator, I need monitoring pages (starting with Operations) to be reachable via stable, shareable links that never depend on tenant context, so that support, alerts, and notifications can deep-link reliably.
|
||||
|
||||
**Why this priority**: Monitoring must be dependable across contexts; deep links are critical for incident response and support.
|
||||
|
||||
**Independent Test**: Visiting the canonical operations URLs works with and without tenant context, and the system enforces membership checks.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a member of a workspace, **When** I visit `/admin/operations`, **Then** I can view a workspace-wide list of operations.
|
||||
2. **Given** I have an active tenant context, **When** I visit `/admin/operations`, **Then** operations are pre-filtered to that tenant but the URL remains `/admin/operations`.
|
||||
3. **Given** I have a run link `/admin/operations/{run}`, **When** I open it, **Then** I see the run detail regardless of tenant context.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Navigate and search without leaking inaccessible data (Priority: P3)
|
||||
|
||||
As a user, I should never learn about workspaces/tenants/runs I cannot access through navigation labels, breadcrumbs, counts, or global search results.
|
||||
|
||||
**Why this priority**: Preventing information leakage is a core enterprise requirement and reduces risk in multi-tenant MSP environments.
|
||||
|
||||
**Independent Test**: An unauthorized user receives not-found responses for out-of-scope resources and does not see them in search.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am not a member of a workspace, **When** I attempt to access that workspace’s monitoring data or runs, **Then** I receive a not-found response.
|
||||
2. **Given** I am a workspace member but lack a capability for a protected workspace-scoped screen or action, **When** I attempt to access it directly, **Then** I receive a forbidden response.
|
||||
3. **Given** I use global search, **When** I search for entities outside my scope, **Then** they do not appear in results (no partial hints).
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- User is a member of zero workspaces.
|
||||
- User loses workspace membership while having an active session.
|
||||
- Tenant context is active but the tenant does not belong to the current workspace.
|
||||
- A run is referenced by an external deep link after it was deleted or moved.
|
||||
- User can view monitoring but cannot perform mutations (e.g., cancel/retry) if those actions exist.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes navigation and authorization behavior but does not introduce new external API calls or background jobs. Any mutation actions added later (e.g., cancel/retry) must follow the platform’s safety gates (confirmation/audit) and be covered by authorization tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):**
|
||||
|
||||
- **Authorization plane(s) involved**:
|
||||
- **Tenant plane (Entra users)** only.
|
||||
- **Platform plane (`/system`) is out of scope** for this feature.
|
||||
- **Authorization planes**:
|
||||
- Workspace-level pages (e.g., monitoring hub, workspace management) are governed by workspace membership and workspace capabilities.
|
||||
- Tenant context is secondary and must not change canonical routing for monitoring pages.
|
||||
- **Isolation model note (workspace scope)**:
|
||||
- “Workspace-scoped” monitoring is an explicit, access-checked aggregation scope over the managed tenants that belong to the selected workspace.
|
||||
- All reads remain bounded to the current workspace; there is no cross-workspace monitoring view in this feature.
|
||||
- **404 vs 403 semantics (strict)**:
|
||||
- Non-member / not entitled to the workspace scope → **404** (deny-as-not-found)
|
||||
- Workspace member but missing the required capability for a protected screen/action → **403**
|
||||
- **Server-side enforcement**: Navigation visibility must not be treated as authorization; all access control is enforced on the server for every protected page and every mutation.
|
||||
- **Global search non-leakage**: Global search must not show titles, counts, or partial matches for inaccessible workspaces/tenants/runs. Inaccessible entities behave as not-found.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (One label, one meaning)**: The application MUST provide two distinct, clearly-labeled entry points:
|
||||
- "Switch workspace" for selecting the active workspace context.
|
||||
- "Manage workspaces" for workspace CRUD/administration.
|
||||
- **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`.
|
||||
- **UX note**: "Switch workspace" is a global context control and MUST NOT be registered as a sidebar navigation item.
|
||||
- **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces".
|
||||
- **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher.
|
||||
- **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context.
|
||||
- **FR-006 (Canonical Operations URLs)**: Operations MUST remain canonical and tenantless:
|
||||
- index: `/admin/operations`
|
||||
- detail: `/admin/operations/{run}`
|
||||
- **FR-007 (Tenant context affects defaults, not routing)**: If tenant context is active, the operations index MUST default to showing runs for that tenant using **server-side default filter state**, and users MUST be able to clear that default to view workspace-wide operations. The canonical URL MUST remain `/admin/operations` and the default MUST present a visible, removable filter chip (no required querystring parameters).
|
||||
- **FR-008 (Tenant shortcut to operations)**: Tenant detail screens MUST offer a "Recent operations" summary and a "View all operations" call-to-action that leads to the canonical operations index.
|
||||
- **FR-009 (Membership gating)**: Users MUST be a member of a workspace to access workspace-scoped pages. Non-members MUST receive a not-found response.
|
||||
- **FR-010 (Capability gating for management)**: Workspace-scoped management/mutations MUST be restricted to users with the appropriate capability/capabilities (from the canonical registry). Unauthorized workspace members MUST receive a forbidden response.
|
||||
- Canonical capabilities used by this feature:
|
||||
- `workspace.manage` (Capabilities::WORKSPACE_MANAGE): create/edit workspace fields.
|
||||
- `workspace_membership.manage` (Capabilities::WORKSPACE_MEMBERSHIP_MANAGE): add/remove members and change roles.
|
||||
- **FR-011 (Monitoring hub IA)**: The sidebar MUST provide a "Monitoring" area that is the canonical home for Operations now, with reserved surfaces for future Alerts and Audit Log.
|
||||
- **FR-012 (Deep-link stability)**: Any monitoring entity intended for support workflows MUST have a stable deep link that does not depend on tenant context.
|
||||
- **FR-013 (No workspace selected)**: If a user visits a workspace-scoped page without a selected workspace context, the system MUST redirect to `/admin/choose-workspace` and then return the user to their originally requested URL after a successful selection.
|
||||
- **FR-014 (Invalid tenant context)**: If tenant context is active but the tenant does not belong to the current workspace, the system MUST auto-clear tenant context and continue on workspace-level pages without tenant scoping.
|
||||
|
||||
- **FR-077-016 (Header context bar)**: The header MUST provide an always-available context bar for Suite navigation:
|
||||
- **FR-077-016-A (Workspace visible)**: If a workspace is selected, show `Workspace: <name>` and allow the user to open the existing workspace switcher (`/admin/choose-workspace`).
|
||||
- **FR-077-016-B (Tenant accessible on tenantless pages)**: The header MUST surface tenant context even on tenantless pages (e.g., `/admin/operations`). If there is an active tenant context, show `Tenant: <tenant name>` (fallback to a safe identifier). If there is no active tenant but there is a last-selected tenant in the current workspace session, show it.
|
||||
- **FR-077-016-C (No implicit switching)**: Canonical pages MUST NOT silently switch tenant or workspace. The context bar is an explicit control only.
|
||||
- **FR-077-016-D (No leakage)**: Tenant picker contents MUST include only tenants the user is entitled to view within the current workspace. Unauthorized tenant selection via direct URL MUST remain deny-as-not-found (404).
|
||||
- **FR-077-016-E (Filament-native)**: Implementation MUST use Filament v5 mechanisms (topbar/user-menu render hooks + Filament tenancy) and Livewire v4 where needed.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: Primary context container for a customer/portfolio.
|
||||
- **Workspace Membership**: The relationship that entitles a user to a workspace.
|
||||
- **Managed Tenant**: Secondary context within a workspace; used for scoping defaults and tenant workflows.
|
||||
- **Operation Run**: A record representing an operational execution that belongs to a workspace and may be associated with a tenant.
|
||||
- **Capability**: A named permission that gates management/mutation behavior.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Reduced confusion)**: In a moderated test with new users, at least 90% correctly choose the right destination (switch vs manage) on first attempt.
|
||||
- **SC-002 (Faster workspace switching)**: Users can switch to a known workspace in under 15 seconds without using search.
|
||||
- **SC-003 (Reliable deep links)**: Support can open `/admin/operations/{run}` successfully regardless of tenant context in 100% of tested cases.
|
||||
- **SC-004 (No leakage regressions)**: Security regression tests confirm 0 instances of inaccessible workspaces/tenants/runs appearing in navigation or global search.
|
||||
|
||||
## Acceptance details (pinned)
|
||||
|
||||
### Recent operations summary (FR-008)
|
||||
|
||||
- Show the most recent **5** operation runs for the current tenant, ordered by `created_at` descending (fallback: `id` descending).
|
||||
- Display, at minimum: `type` (label), `status`, `outcome`, `created_at` (or since), and a link to the run detail.
|
||||
- Provide a "View all operations" CTA that navigates to canonical `/admin/operations` (no tenant prefix / no required query params).
|
||||
|
||||
### Header context bar (FR-077-016)
|
||||
|
||||
- The header shows a stable, compact context bar:
|
||||
- `Workspace: <name>` (clickable)
|
||||
- `Tenant: <name>` (picker)
|
||||
- Tenant picker is available on tenantless pages.
|
||||
- No automatic tenant selection occurs when opening canonical URLs.
|
||||
|
||||
## Mandatory Tests (pinned)
|
||||
|
||||
- **T-077-016-1 (Tenant dropdown on tenantless pages)**: With a selected workspace and an active tenant context, visiting `/admin/operations` shows the tenant picker and selecting a tenant navigates to tenant home.
|
||||
- **T-077-016-2 (Security filtering)**: Only entitled tenants within the current workspace appear in the picker; posting /navigating to an unauthorized tenant results in 404.
|
||||
- **T-077-016-3 (No implicit switching)**: Visiting `/admin/operations/{run}` from a deep link MUST NOT auto-switch tenant.
|
||||
220
specs/077-workspace-nav-monitoring-hub/tasks.md
Normal file
220
specs/077-workspace-nav-monitoring-hub/tasks.md
Normal file
@ -0,0 +1,220 @@
|
||||
---
|
||||
|
||||
description: "Task list for Spec 077 implementation"
|
||||
---
|
||||
|
||||
# Tasks: Workspace-first Navigation & Monitoring Hub (077)
|
||||
|
||||
**Input**: Design documents from `/specs/077-workspace-nav-monitoring-hub/`
|
||||
|
||||
**Prerequisites**:
|
||||
- Required: [spec.md](spec.md), [plan.md](plan.md)
|
||||
- Optional (used): [research.md](research.md), [data-model.md](data-model.md), [contracts/routes.md](contracts/routes.md), [quickstart.md](quickstart.md)
|
||||
|
||||
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (navigation + authorization + filtering).
|
||||
|
||||
**Livewire/Filament compatibility**: Filament v5 + Livewire v4 only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare the minimal scaffolding for safe, test-first delivery.
|
||||
|
||||
- [X] T001 Create new Pest test file for workspace navigation in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||
- [X] T002 Create new Pest test file for operations canonical routing in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
- [X] T003 [P] Create new Pest test file for non-leakage semantics in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared plumbing needed by multiple stories.
|
||||
|
||||
- [X] T004 Add intended-URL session key constant in app/Support/Workspaces/WorkspaceContext.php
|
||||
- [X] T005 Implement “store intended URL” helper in app/Support/Workspaces/WorkspaceIntendedUrl.php
|
||||
- [X] T006 [P] Add tests for intended-URL helper in tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
|
||||
- [X] T007 Update middleware to use intended-URL helper in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [X] T008 [P] Add safe-redirect allowlist for intended URLs in app/Support/Workspaces/WorkspaceIntendedUrl.php
|
||||
|
||||
**Checkpoint**: Intended redirect plumbing exists and is covered by tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Switch workspace without ambiguity (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Clear separation between “Switch workspace” and “Manage workspaces”, with correct 404/403 behavior.
|
||||
|
||||
**Independent Test**: A signed-in user can switch workspaces via “Switch workspace”, and “Manage workspaces” is only visible/accessible when authorized.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T009 [P] [US1] Assert nav label “Switch workspace” appears when tenant is not selected in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||
- [X] T010 [P] [US1] Assert no ambiguous “Workspaces” nav item exists in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||
- [X] T011 [P] [US1] Assert `/admin/workspaces` is tenantless and reachable for a workspace owner in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
|
||||
- [X] T012 [P] [US1] Assert `/admin/workspaces/{record}` is deny-as-not-found for non-members (404) in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Rename fallback nav item to “Switch workspace” in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T014 [US1] Update user-menu copy/CTA to “Switch workspace” in resources/views/filament/partials/workspace-switcher.blade.php
|
||||
- [X] T015 [US1] Rename admin sidebar item to “Manage workspaces” in app/Providers/Filament/AdminPanelProvider.php
|
||||
- [X] T016 [US1] Gate “Manage workspaces” navigation visibility via capability in app/Providers/Filament/AdminPanelProvider.php
|
||||
- [X] T017 [US1] Enforce workspace-scoped RBAC semantics for workspace management (404 non-member, 403 missing capability) in app/Policies/WorkspacePolicy.php
|
||||
- [X] T018 [US1] Ensure workspace management breadcrumbs point to `/admin/workspaces` in app/Filament/Resources/Workspaces/WorkspaceResource.php
|
||||
- [X] T019 [US1] Ensure `/admin/workspaces` routes do not require tenant context in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T020 [US1] Run focused tests for US1 via `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub` (document in specs/077-workspace-nav-monitoring-hub/quickstart.md)
|
||||
|
||||
**Checkpoint**: UI uses unambiguous labels; `/admin/workspaces` follows workspace RBAC semantics (no leakage).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Use Monitoring hub from canonical links (Priority: P2)
|
||||
|
||||
**Goal**: `/admin/operations` and `/admin/operations/{run}` work regardless of tenant context; tenant context only sets removable default filters.
|
||||
|
||||
**Independent Test**: Visiting `/admin/operations` works tenantless (workspace-selected), and in tenant context it defaults to that tenant via a removable filter chip.
|
||||
|
||||
### Tests for User Story 2 (write first)
|
||||
|
||||
- [X] T021 [P] [US2] Assert `/admin/operations` is reachable without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
- [X] T022 [P] [US2] Assert `/admin/operations/{run}` works with and without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
- [X] T023 [P] [US2] Assert operations list defaults to current tenant (filter state) when tenant context active in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
- [X] T024 [P] [US2] Assert clearing tenant filter shows workspace-wide runs in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T025 [US2] Allow `/admin/operations` (index) through tenancy-enforcing middleware without auto-setting tenant in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T026 [US2] Ensure workspace selection is required for `/admin/operations` and stores intended URL for return flow in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [X] T027 [US2] Redirect back to intended URL after workspace selection in both app/Filament/Pages/ChooseWorkspace.php and app/Http/Controllers/SwitchWorkspaceController.php
|
||||
- [X] T028 [US2] Remove hard tenant scoping from query in app/Filament/Resources/OperationRunResource.php
|
||||
- [X] T029 [US2] Add tenant SelectFilter with removable chip and server-side default state in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T030 [US2] Scope selectable tenants in the filter to current workspace in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T031 [US2] Add “Recent operations” summary (last 5 by created_at) + “View all operations” CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||
- [X] T032 [US2] Ensure “View all operations” CTA routes to canonical `/admin/operations` in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||
- [X] T033 [US2] Ensure operations pages remain DB-only (no Graph calls) by extending existing checks in tests/Feature/MonitoringOperationsTest.php
|
||||
- [X] T034 [US2] Run focused tests for US2 via `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls` and update specs/077-workspace-nav-monitoring-hub/quickstart.md
|
||||
|
||||
**Checkpoint**: Canonical operations URLs work; tenant context only affects default filter state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Navigate and search without leaking inaccessible data (Priority: P3)
|
||||
|
||||
**Goal**: Enforce strict 404 vs 403 semantics without leaking admin surfaces or cross-workspace/tenant data.
|
||||
|
||||
**Independent Test**: Non-members get 404; members missing capability get 403, and no navigation labels hint at inaccessible features.
|
||||
|
||||
### Tests for User Story 3 (write first)
|
||||
|
||||
- [X] T035 [P] [US3] Assert non-member access to another workspace’s operations is 404 in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||
- [X] T036 [P] [US3] Assert member missing `workspace.manage` gets 403 on `/admin/workspaces/{record}/edit` in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||
- [X] T037 [P] [US3] Assert invalid tenant context is auto-cleared when switching workspace in tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php
|
||||
- [X] T038 [P] [US3] Assert reserved Monitoring placeholder pages exist (`/admin/alerts`, `/admin/audit-log`) in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T039 [US3] Implement “auto-clear invalid tenant context” check in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T040 [US3] Confirm and implement correct Filament v5 mechanism for clearing persisted tenant state in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T041 [US3] Implement reserved Monitoring placeholder pages (Alerts, Audit Log) as Filament pages under app/Filament/Pages/Monitoring/**
|
||||
- [X] T042 [US3] Ensure navigation does not expose admin-only surfaces to unauthorized users in app/Providers/Filament/AdminPanelProvider.php
|
||||
- [X] T043 [US3] Verify global search does not introduce new leakage for operations/workspaces and, if needed, disable global search for resources without view/edit pages in app/Filament/**
|
||||
- [X] T044 [US3] Run focused tests for US3 via `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
|
||||
|
||||
**Checkpoint**: 404/403 behavior matches spec; no cross-scope leaks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Stabilize, format, and validate end-to-end.
|
||||
|
||||
- [X] T045 Run formatter on touched files via `./vendor/bin/sail bin pint --dirty`
|
||||
- [X] T046 Run targeted full suite for touched areas via `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
|
||||
- [X] T047 [P] Confirm manual quickstart steps still match UI labels and routes in specs/077-workspace-nav-monitoring-hub/quickstart.md
|
||||
- [X] T048 [P] Confirm route semantics still match contracts in specs/077-workspace-nav-monitoring-hub/contracts/routes.md
|
||||
- [X] T049 Ensure Filament v5 + Livewire v4 APIs are used (no v3/v4 Filament APIs) in app/Filament/**
|
||||
- [X] T050 Run full suite (optional) via `./vendor/bin/sail artisan test --compact`
|
||||
|
||||
### Post-implementation bugfixes
|
||||
|
||||
- [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php
|
||||
- [X] T059 Remove “Switch workspace” from sidebar navigation (workspace switching is topbar-only) in app/Providers/Filament/AdminPanelProvider.php and app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T060 Define Global Mode: make `/admin/workspaces` workspace-optional + add explicit allowlist in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [X] T061 Disable tenant picker when no workspace is active (Global Mode) in resources/views/filament/partials/context-bar.blade.php
|
||||
- [X] T062 Remove “Manage workspaces” link from the topbar context switcher to avoid redundant entry points in resources/views/filament/partials/context-bar.blade.php
|
||||
- [X] T063 Unify workspace creation authorization: ChooseWorkspace create action must use WorkspacePolicy (Gate) in app/Filament/Pages/ChooseWorkspace.php and app/Policies/WorkspacePolicy.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Addendum — Header Context Bar (FR-077-016)
|
||||
|
||||
**Goal**: Always-visible context bar for Workspace + Tenant, usable on tenantless pages without implicit switching.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T051 [P] [FR-077-016] Assert tenant picker renders on `/admin/operations` in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||
- [X] T052 [P] [FR-077-016] Assert tenant picker lists only entitled tenants in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||
- [X] T053 [P] [FR-077-016] Assert deep link `/admin/operations/{run}` does not auto-switch tenant in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T054 [FR-077-016] Render context bar in topbar via render hook in app/Providers/Filament/AdminPanelProvider.php
|
||||
- [X] T055 [FR-077-016] Add context bar partial view in resources/views/filament/partials/context-bar.blade.php
|
||||
- [X] T056 [FR-077-016] Remove implicit tenant auto-selection behavior while preserving deny-as-not-found semantics in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T057 [FR-077-016] Persist last-selected tenant per workspace session in app/Support/Workspaces/WorkspaceContext.php and controllers/pages that select tenants
|
||||
|
||||
**Checkpoint**: Tenant picker usable on tenantless pages; no silent tenant switching.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) → Phase 2 (Foundational)
|
||||
- Phase 2 (Foundational) → Phase 3+ (User stories)
|
||||
- Phase 3 (US1) is the MVP and should be delivered first.
|
||||
- Phase 4 (US2) depends on the clarified navigation + intended-URL plumbing (Phases 1–2).
|
||||
- Phase 5 (US3) depends on the implemented behavior from US1/US2 so it can assert non-leakage.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 → US2: soft dependency (naming + intended redirect improves US2 flows)
|
||||
- US2 → US3: recommended dependency (US3 asserts final 404/403 and filter semantics)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1 parallelizable work
|
||||
|
||||
- T009, T010, T011, T012 can be written in parallel (different assertions/files)
|
||||
- T013, T014, T015 can be implemented in parallel (different files)
|
||||
|
||||
### US2 parallelizable work
|
||||
|
||||
- T021–T024 can be written in parallel
|
||||
- T028 (resource query) and T029 (table filters) can be implemented in parallel
|
||||
- T031–T032 can be implemented in parallel with operations filter work (different file)
|
||||
|
||||
### US3 parallelizable work
|
||||
|
||||
- T035–T038 can be written in parallel
|
||||
- T039–T042 can be implemented in parallel (different files)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope (recommended)
|
||||
|
||||
- Deliver Phase 1–3 (US1) only.
|
||||
- Validate with `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`.
|
||||
- Demo “Switch workspace” vs “Manage workspaces” clarity + correct 404/403 behavior.
|
||||
|
||||
### Incremental delivery
|
||||
|
||||
- Add US2 (canonical operations URLs + removable tenant default filter)
|
||||
- Add US3 (non-leakage regression guards)
|
||||
- Finish with Phase 6 polish and a full suite run
|
||||
47
tests/Feature/Badges/VerificationBadgeSemanticsTest.php
Normal file
47
tests/Feature/Badges/VerificationBadgeSemanticsTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps verification check status fail to a Fail danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
|
||||
|
||||
expect($spec->label)->toBe('Fail');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('normalizes verification check status input before mapping', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'RUNNING');
|
||||
|
||||
expect($spec->label)->toBe('Running');
|
||||
});
|
||||
|
||||
it('maps verification check severity critical to a Critical danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
|
||||
|
||||
expect($spec->label)->toBe('Critical');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('maps empty verification check severity to an Unknown badge (v1.5 allows empty severity)', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, '');
|
||||
|
||||
expect($spec->label)->toBe('Unknown');
|
||||
});
|
||||
|
||||
it('maps verification report overall needs_attention to a Needs attention warning badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
|
||||
|
||||
expect($spec->label)->toBe('Needs attention');
|
||||
expect($spec->color)->toBe('warning');
|
||||
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
|
||||
});
|
||||
|
||||
it('normalizes verification report overall input before mapping', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'NEEDS ATTENTION');
|
||||
|
||||
expect($spec->label)->toBe('Needs attention');
|
||||
});
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
->assertDontSee($tenantB->name);
|
||||
});
|
||||
|
||||
test('user menu renders a workspace switcher when a workspace is selected', function () {
|
||||
test('user menu does not render a workspace switcher (topbar context bar is the single entry point)', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
|
||||
@ -61,6 +61,5 @@
|
||||
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee($workspace->name)
|
||||
->assertSee('Switch workspace')
|
||||
->assertSee('name="workspace_id"', escape: false);
|
||||
->assertDontSee('name="workspace_id"', escape: false);
|
||||
});
|
||||
|
||||
173
tests/Feature/Monitoring/HeaderContextBarTest.php
Normal file
173
tests/Feature/Monitoring/HeaderContextBarTest.php
Normal file
@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('renders the tenant context picker on tenantless Monitoring → Operations', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$workspaceName = $tenant->workspace?->name;
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee($workspaceName ?? 'Select workspace')
|
||||
->assertSee('Select tenant')
|
||||
->assertSee('Search tenants…')
|
||||
->assertSee('Switch workspace')
|
||||
->assertSee('admin/select-tenant')
|
||||
->assertSee('Clear tenant context')
|
||||
->assertSee($tenant->getFilamentName());
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()])
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
it('disables the tenant picker when no workspace is active (Global Mode)', function (): void {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
\App\Models\WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Select workspace')
|
||||
->assertSee('Select tenant')
|
||||
->assertSee('Choose a workspace first.')
|
||||
->assertDontSee('Search tenants…');
|
||||
});
|
||||
|
||||
it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee($tenant->getFilamentName())
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant')
|
||||
->assertDontSee('Clear tenant context');
|
||||
});
|
||||
|
||||
it('filters the header tenant picker to tenants the user can access', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'ZZZ-UNAUTHORIZED-TENANT-NAME-12345',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee($tenantA->getFilamentName())
|
||||
->assertDontSee($tenantB->getFilamentName());
|
||||
});
|
||||
|
||||
it('shows all workspace tenants in the header tenant picker for workspace owners', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'ZZZ-UNASSIGNED-TENANT-NAME-12345',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee($tenantA->getFilamentName())
|
||||
->assertSee($tenantB->getFilamentName());
|
||||
});
|
||||
|
||||
it('does not implicitly switch tenant when opening canonical operation deep links', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$runA = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'initiator_name' => 'TenantA',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory.sync',
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
])
|
||||
->get(route('admin.operations.view', ['run' => (int) $runA->getKey()]))
|
||||
->assertOk();
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee('TenantA')
|
||||
->assertSee('TenantB');
|
||||
});
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@ -21,8 +22,11 @@
|
||||
Bus::fake();
|
||||
Queue::fake();
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant) {
|
||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
|
||||
143
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
Normal file
143
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('serves /admin/operations without tenant context (workspace-wide)', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$runA = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'initiator_name' => 'TenantA',
|
||||
]);
|
||||
|
||||
$runB = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory.sync',
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee('TenantA')
|
||||
->assertSee('TenantB');
|
||||
});
|
||||
|
||||
it('serves /admin/operations/{run} with and without tenant context', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Operation run')
|
||||
->assertSee('/admin/t/'.((int) $tenant->getKey()).'/operations/r/'.((int) $run->getKey()));
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$runA = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'initiator_name' => 'TenantA',
|
||||
]);
|
||||
|
||||
$runB = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory.sync',
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
session([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListOperationRuns::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB]);
|
||||
|
||||
$component
|
||||
->filterTable('tenant_id', null)
|
||||
->assertCanSeeTableRecords([$runA, $runB]);
|
||||
});
|
||||
|
||||
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/alerts')
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/audit-log')
|
||||
->assertOk();
|
||||
});
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('renders Monitoring → Operations index DB-only (no outbound HTTP, no background work)', function () {
|
||||
@ -19,8 +20,11 @@
|
||||
|
||||
Bus::fake();
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant) {
|
||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Total Runs (30 days)')
|
||||
->assertSee('Active Runs')
|
||||
@ -51,10 +55,13 @@
|
||||
|
||||
Bus::fake();
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant, $run) {
|
||||
$this->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Policy sync');
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('scopes Monitoring → Operations list to the active tenant', function () {
|
||||
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
@ -33,8 +35,11 @@
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('TenantA')
|
||||
@ -48,6 +53,8 @@
|
||||
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
@ -103,6 +110,13 @@
|
||||
$tenantA->makeCurrent();
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
session([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListOperationRuns::class)
|
||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||
@ -121,16 +135,12 @@
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
||||
});
|
||||
|
||||
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
|
||||
it('prevents cross-workspace access to Monitoring → Operations detail', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$runB = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
@ -140,6 +150,7 @@
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('allows access to monitoring page for tenant members', function () {
|
||||
it('allows access to Monitoring → Operations for workspace members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
@ -19,18 +23,22 @@
|
||||
'run_identity_hash' => 'hash123',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertSuccessful()
|
||||
->assertSee('Policy sync');
|
||||
});
|
||||
|
||||
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
||||
it('renders Monitoring → Operations pages DB-only (never calls Graph)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
@ -47,59 +55,62 @@
|
||||
$mock->shouldReceive('request')->never();
|
||||
});
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('shows runs only for current tenant', function () {
|
||||
it('defaults the operations list to the active tenant when tenant context is set', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
// We must simulate being in tenant context
|
||||
$this->actingAs($user);
|
||||
// Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
// Easier approach: visit the page for tenantA
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
OperationRun::create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
'run_identity_hash' => 'hashA',
|
||||
'initiator_name' => 'TenantA',
|
||||
]);
|
||||
|
||||
OperationRun::create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
'run_identity_hash' => 'hashB',
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
// Livewire::test needs to know the tenant if the component relies on it.
|
||||
// However, the component relies on `Filament::getTenant()`.
|
||||
// The cleanest way is to just GET the page URL, which runs middleware.
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertSee('Policy sync')
|
||||
->assertDontSee('Inventory sync');
|
||||
});
|
||||
|
||||
it('allows readonly users to view operations list and detail', function () {
|
||||
it('allows readonly users to view operations list and detail', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||
|
||||
$run = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
@ -107,30 +118,27 @@
|
||||
'run_identity_hash' => 'hash123',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertSuccessful()
|
||||
->assertSee('Policy sync');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Policy sync');
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
it('denies access to unauthorized users', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
it('returns 404 when viewing an operation run outside workspace membership', function (): void {
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$user = User::factory()->create();
|
||||
// Not attached to tenant
|
||||
|
||||
// In a multitenant app, if you try to access a tenant route you are not part of,
|
||||
// Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
|
||||
// The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
|
||||
// We should accept 404 or 403.
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(OperationRunResource::getUrl('index', tenant: $tenant));
|
||||
|
||||
// Allow either 403 or 404 as "Denied"
|
||||
$this->assertTrue(in_array($response->status(), [403, 404]));
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
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,8 +8,10 @@
|
||||
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\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -102,13 +104,19 @@
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
],
|
||||
'failure_summary' => [
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'code' => 'provider.connection.check.failed',
|
||||
'key' => 'permission_check',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
@ -127,7 +135,136 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('permission_denied')
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee('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();
|
||||
});
|
||||
|
||||
222
tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
Normal file
222
tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
Normal file
@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('shows exactly one verification CTA depending on run state', 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(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->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(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Start verification')
|
||||
->assertDontSee('Refresh');
|
||||
|
||||
$runningRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update([
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $runningRun->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Refresh')
|
||||
->assertDontSee('Start verification');
|
||||
});
|
||||
|
||||
it('orders issues deterministically and groups acknowledged issues', 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(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$checks = [
|
||||
[
|
||||
'key' => 'acknowledged_fail',
|
||||
'title' => 'Acked failure',
|
||||
'status' => 'fail',
|
||||
'severity' => 'medium',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'invalid_state',
|
||||
'message' => 'Already known.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'warning',
|
||||
'title' => 'Warning check',
|
||||
'status' => 'warn',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'not_applicable',
|
||||
'message' => 'Something is slightly off.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'failure',
|
||||
'title' => 'Failure check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'missing_configuration',
|
||||
'message' => 'This must be fixed.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'blocker',
|
||||
'title' => 'Blocker check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Cannot proceed.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [
|
||||
['label' => 'First step', 'url' => '/admin/help/first'],
|
||||
['label' => 'Second step', 'url' => '/admin/help/second'],
|
||||
['label' => 'Third step', 'url' => '/admin/help/third'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'pass',
|
||||
'title' => 'Passed check',
|
||||
'status' => 'pass',
|
||||
'severity' => '',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Looks good.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$report = 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' => 'failed',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
VerificationCheckAcknowledgement::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'check_key' => 'acknowledged_fail',
|
||||
'ack_reason' => 'Known issue accepted.',
|
||||
'acknowledged_by_user_id' => (int) $user->getKey(),
|
||||
'acknowledged_at' => now(),
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->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(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Read-only')
|
||||
->assertSeeInOrder([
|
||||
'Blocker check',
|
||||
'Failure check',
|
||||
'Warning check',
|
||||
'Acknowledged issues',
|
||||
'Acked failure',
|
||||
])
|
||||
->assertSee('Known issue accepted.')
|
||||
->assertSee('First step')
|
||||
->assertSee('Second step')
|
||||
->assertDontSee('Third step');
|
||||
});
|
||||
59
tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
Normal file
59
tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 when a non-member tries to view another workspace operation run', function (): void {
|
||||
$workspaceA = Workspace::factory()->create();
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspaceA->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
]);
|
||||
|
||||
$runB = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'initiator_name' => 'WorkspaceB',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 when a workspace member without workspace.manage tries to edit a workspace', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get('/admin/workspaces/'.(int) $workspace->getKey().'/edit')
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user