Compare commits
6 Commits
feat/072-m
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a604cfb6 | |||
| 53dc89e6ef | |||
| 8e34b6084f | |||
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a |
@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
coverage/
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -14,6 +14,8 @@ ## Active Technologies
|
||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -33,9 +35,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
@ -73,11 +72,6 @@ public function selectTenant(int $tenantId): void
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
public function canRegisterTenant(): bool
|
||||
{
|
||||
return RegisterTenantPage::canView();
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantlessOperationRunViewer extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $title = 'Operation run';
|
||||
|
||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
||||
|
||||
public OperationRun $run;
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => url()->current()),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$isMember = WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
|
||||
if (! $isMember) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
2164
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
2164
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -48,11 +47,6 @@ public function getTenants(): Collection
|
||||
->get();
|
||||
}
|
||||
|
||||
public function canRegisterTenant(): bool
|
||||
{
|
||||
return RegisterTenantPage::canView();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
{
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
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;
|
||||
@ -136,12 +141,92 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Verification report')
|
||||
->schema([
|
||||
ViewEntry::make('verification_report')
|
||||
->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 = Tenant::current();
|
||||
|
||||
$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))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Context')
|
||||
->schema([
|
||||
ViewEntry::make('context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = $record->context ?? [];
|
||||
$context = is_array($context) ? $context : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
$context['verification_report'] = [
|
||||
'redacted' => true,
|
||||
'note' => 'Rendered in the Verification report section.',
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -15,11 +14,13 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -99,9 +100,16 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
if ($workspaceId === null) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
->columns([
|
||||
@ -175,29 +183,22 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$result = $gate->start(
|
||||
$result = $verification->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
@ -640,9 +641,17 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -14,6 +13,7 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -167,7 +167,7 @@ protected function getHeaderActions(): array
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
$result = $verification->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
|
||||
@ -25,11 +25,22 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('Email'))
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->label(__('Domain'))
|
||||
->getStateUsing(function (TenantMembership $record): ?string {
|
||||
$email = $record->user?->email;
|
||||
|
||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) str($email)->after('@')->lower();
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('Name'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
@ -49,7 +60,13 @@ public function table(Table $table): Table
|
||||
->label(__('User'))
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||
->options(fn () => User::query()
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Services\Auth\WorkspaceMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -26,11 +26,22 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('Email'))
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->label(__('Domain'))
|
||||
->getStateUsing(function (WorkspaceMembership $record): ?string {
|
||||
$email = $record->user?->email;
|
||||
|
||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) str($email)->after('@')->lower();
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('Name'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
@ -38,7 +49,7 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->headerActions([
|
||||
UiEnforcement::forTableAction(
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Action::make('add_member')
|
||||
->label(__('Add member'))
|
||||
->icon('heroicon-o-plus')
|
||||
@ -47,7 +58,13 @@ public function table(Table $table): Table
|
||||
->label(__('User'))
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||
->options(fn () => User::query()
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
@ -105,7 +122,7 @@ public function table(Table $table): Table
|
||||
->apply(),
|
||||
])
|
||||
->actions([
|
||||
UiEnforcement::forTableAction(
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Action::make('change_role')
|
||||
->label(__('Change role'))
|
||||
->icon('heroicon-o-pencil')
|
||||
@ -159,7 +176,7 @@ public function table(Table $table): Table
|
||||
->tooltip('You do not have permission to manage workspace memberships.')
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forTableAction(
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Action::make('remove')
|
||||
->label(__('Remove'))
|
||||
->color('danger')
|
||||
|
||||
@ -17,6 +17,8 @@ class WorkspaceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Workspace::class;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Filament/Support/VerificationReportViewer.php
Normal file
92
app/Filament/Support/VerificationReportViewer.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
|
||||
final class VerificationReportViewer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function report(OperationRun $run): ?array
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
if (! is_array($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
return redirect()->route('admin.onboarding');
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
|
||||
@ -32,6 +32,19 @@ public function handle(Request $request, Closure $next): Response
|
||||
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);
|
||||
}
|
||||
|
||||
@ -7,11 +7,17 @@
|
||||
use App\Models\ProviderConnection;
|
||||
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;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -83,17 +89,146 @@ public function handle(
|
||||
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$permissionService = app(TenantPermissionService::class);
|
||||
|
||||
$graphOptions = null;
|
||||
|
||||
if ($result->healthy) {
|
||||
$runs->updateRun(
|
||||
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: [
|
||||
[
|
||||
'key' => 'provider.connection.check',
|
||||
'title' => 'Provider connection check',
|
||||
'status' => $result->healthy ? 'pass' : 'fail',
|
||||
'severity' => $result->healthy ? 'info' : 'critical',
|
||||
'blocking' => ! $result->healthy,
|
||||
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||
'evidence' => array_values(array_filter([
|
||||
[
|
||||
'kind' => 'provider_connection_id',
|
||||
'value' => (int) $connection->getKey(),
|
||||
],
|
||||
[
|
||||
'kind' => 'entra_tenant_id',
|
||||
'value' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||
'kind' => 'http_status',
|
||||
'value' => (int) $result->meta['http_status'],
|
||||
] : null,
|
||||
is_string($result->meta['organization_id'] ?? null) ? [
|
||||
'kind' => 'organization_id',
|
||||
'value' => (string) $result->meta['organization_id'],
|
||||
] : null,
|
||||
])),
|
||||
'next_steps' => $result->healthy
|
||||
? []
|
||||
: [[
|
||||
'label' => 'Review provider connection',
|
||||
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||
'record' => (int) $connection->getKey(),
|
||||
], tenant: $tenant),
|
||||
]],
|
||||
],
|
||||
...$permissionChecks,
|
||||
],
|
||||
identity: [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
);
|
||||
|
||||
if ($result->healthy) {
|
||||
$run = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$runs->updateRun(
|
||||
$run = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
@ -103,6 +238,8 @@ public function handle(
|
||||
'message' => $result->message ?? 'Health check failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||
@ -145,4 +282,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
|
||||
{
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = $report['summary']['counts'] ?? [];
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCompleted->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'counts' => $counts,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,11 +21,41 @@ class OperationRun extends Model
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $operationRun): void {
|
||||
if ($operationRun->workspace_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operationRun->tenant_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenant->workspace_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
@ -26,6 +26,11 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function credential(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
||||
|
||||
@ -21,6 +21,14 @@ class Tenant extends Model implements HasName
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_ONBOARDING = 'onboarding';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
@ -69,7 +77,16 @@ protected static function booted(): void
|
||||
}
|
||||
|
||||
if (empty($tenant->status)) {
|
||||
$tenant->status = 'active';
|
||||
$tenant->status = self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
if ($tenant->workspace_id === null && app()->runningUnitTests()) {
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => 'Test Workspace',
|
||||
'slug' => 'test-'.Str::lower(Str::random(10)),
|
||||
]);
|
||||
|
||||
$tenant->workspace_id = (int) $workspace->getKey();
|
||||
}
|
||||
});
|
||||
|
||||
@ -84,12 +101,12 @@ protected static function booted(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant->status = 'archived';
|
||||
$tenant->status = self::STATUS_ARCHIVED;
|
||||
$tenant->saveQuietly();
|
||||
});
|
||||
|
||||
static::restored(function (Tenant $tenant) {
|
||||
$tenant->forceFill(['status' => 'active'])->saveQuietly();
|
||||
$tenant->forceFill(['status' => self::STATUS_ACTIVE])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
@ -97,12 +114,12 @@ public static function activeQuery(): Builder
|
||||
{
|
||||
return static::query()
|
||||
->whereNull('deleted_at')
|
||||
->where('status', 'active');
|
||||
->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function makeCurrent(): void
|
||||
{
|
||||
if ($this->trashed() || $this->status !== 'active') {
|
||||
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
|
||||
throw new RuntimeException('Only active tenants can be made current.');
|
||||
}
|
||||
|
||||
|
||||
92
app/Models/TenantOnboardingSession.php
Normal file
92
app/Models/TenantOnboardingSession.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantOnboardingSession extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'managed_tenant_onboarding_sessions';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const STATE_ALLOWED_KEYS = [
|
||||
'entra_tenant_id',
|
||||
'tenant_id',
|
||||
'tenant_name',
|
||||
'environment',
|
||||
'primary_domain',
|
||||
'notes',
|
||||
'provider_connection_id',
|
||||
'selected_provider_connection_id',
|
||||
'verification_operation_run_id',
|
||||
'verification_run_id',
|
||||
'bootstrap_operation_types',
|
||||
'bootstrap_operation_runs',
|
||||
'bootstrap_run_ids',
|
||||
'connection_recently_updated',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'state' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $value
|
||||
*/
|
||||
public function setStateAttribute(?array $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
$this->attributes['state'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$allowed = array_intersect_key($value, array_flip(self::STATE_ALLOWED_KEYS));
|
||||
|
||||
$encoded = json_encode($allowed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->attributes['state'] = $encoded !== false ? $encoded : json_encode([], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function startedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'started_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function updatedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,8 +33,20 @@ public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$wizard = $context['wizard'] ?? null;
|
||||
|
||||
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||
|
||||
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||
|
||||
$runUrl = match (true) {
|
||||
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||
@ -42,7 +54,7 @@ public function toDatabase(object $notifiable): array
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
|
||||
->url($runUrl),
|
||||
])
|
||||
->getDatabaseMessage();
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
@ -14,31 +15,31 @@ class OperationRunPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if (! $tenant) {
|
||||
if ($workspaceId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function view(User $user, OperationRun $run): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||
if ($workspaceId <= 0) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
$isMember = WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
|
||||
return $isMember ? true : Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -15,15 +17,31 @@ class ProviderConnectionPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return Gate::forUser($user)->allows('provider.view', $tenant);
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
&& Gate::forUser($user)->allows('provider.view', $tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
||||
return false;
|
||||
}
|
||||
@ -32,20 +50,40 @@ public function view(User $user, ProviderConnection $connection): Response|bool
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return Gate::forUser($user)->allows('provider.manage', $tenant);
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
&& Gate::forUser($user)->allows('provider.manage', $tenant);
|
||||
}
|
||||
|
||||
public function update(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
||||
return false;
|
||||
}
|
||||
@ -54,13 +92,26 @@ public function update(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
|
||||
return false;
|
||||
}
|
||||
@ -69,6 +120,19 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function currentWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_int($workspaceId)
|
||||
? Workspace::query()->whereKey($workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
@ -23,19 +25,36 @@ public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
||||
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool {
|
||||
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resolver->can($user, $tenant, $capability);
|
||||
return $tenantResolver->can($user, $tenant, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
||||
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $workspaceResolver->can($user, $workspace, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
foreach (Capabilities::all() as $capability) {
|
||||
if (str_starts_with($capability, 'workspace')) {
|
||||
$defineWorkspaceCapability($capability);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$defineTenantCapability($capability);
|
||||
}
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
@ -42,25 +42,20 @@ public function panel(Panel $panel): Panel
|
||||
ChooseWorkspace::registerRoutes($panel);
|
||||
ChooseTenant::registerRoutes($panel);
|
||||
NoAccess::registerRoutes($panel);
|
||||
|
||||
WorkspaceResource::registerRoutes($panel);
|
||||
})
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||
->searchableTenantMenu()
|
||||
->tenantRegistration(RegisterTenant::class)
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(function (): string {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]);
|
||||
}
|
||||
|
||||
return ChooseWorkspace::getUrl();
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class WorkspaceAuditLogger
|
||||
@ -26,6 +27,10 @@ public function log(
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
@ -36,7 +41,7 @@ public function log(
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $metadata + $context,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -23,17 +23,39 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_ARCHIVE,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||
],
|
||||
|
||||
WorkspaceRole::Readonly->value => [
|
||||
|
||||
@ -6,6 +6,25 @@
|
||||
|
||||
class GraphContractRegistry
|
||||
{
|
||||
public function probePath(string $key, array $replacements = []): ?string
|
||||
{
|
||||
$path = config("graph_contracts.probes.$key.path");
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($replacements as $placeholder => $value) {
|
||||
if (! is_string($placeholder) || $placeholder === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = str_replace($placeholder, urlencode((string) $value), $path);
|
||||
}
|
||||
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function directoryGroupsPolicyType(): string
|
||||
{
|
||||
return 'directoryGroups';
|
||||
|
||||
@ -409,7 +409,20 @@ private function shouldApplySelectFallback(GraphResponse $graphResponse, array $
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
$context = $this->resolveContext($options);
|
||||
$endpoint = 'organization';
|
||||
$endpoint = $this->contracts->probePath('organization');
|
||||
|
||||
if (! is_string($endpoint) || $endpoint === '') {
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 500,
|
||||
errors: [[
|
||||
'message' => 'Graph contract missing for probe: organization',
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
$endpoint = ltrim($endpoint, '/');
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
$fullPath = $this->buildFullPath($endpoint);
|
||||
|
||||
@ -479,14 +492,27 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
|
||||
// First, get the service principal object by clientId (appId)
|
||||
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
|
||||
$endpoint = $this->contracts->probePath('service_principal_by_app_id', ['{appId}' => $clientId]);
|
||||
|
||||
if (! is_string($endpoint) || $endpoint === '') {
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 500,
|
||||
errors: [[
|
||||
'message' => 'Graph contract missing for probe: service_principal_by_app_id',
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
$endpoint = ltrim($endpoint, '/');
|
||||
|
||||
$this->logger->logRequest('get_service_principal', [
|
||||
'endpoint' => $endpoint,
|
||||
'client_id' => $clientId,
|
||||
'tenant' => $context['tenant'],
|
||||
'method' => 'GET',
|
||||
'full_path' => $endpoint,
|
||||
'full_path' => $this->buildFullPath($endpoint),
|
||||
'client_request_id' => $clientRequestId,
|
||||
]);
|
||||
|
||||
@ -528,14 +554,30 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
}
|
||||
|
||||
// Now get the app role assignments (application permissions)
|
||||
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
|
||||
$assignmentsEndpoint = $this->contracts->probePath(
|
||||
'service_principal_app_role_assignments',
|
||||
['{servicePrincipalId}' => $servicePrincipalId],
|
||||
);
|
||||
|
||||
if (! is_string($assignmentsEndpoint) || $assignmentsEndpoint === '') {
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 500,
|
||||
errors: [[
|
||||
'message' => 'Graph contract missing for probe: service_principal_app_role_assignments',
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
$assignmentsEndpoint = ltrim($assignmentsEndpoint, '/');
|
||||
|
||||
$this->logger->logRequest('get_app_role_assignments', [
|
||||
'endpoint' => $assignmentsEndpoint,
|
||||
'service_principal_id' => $servicePrincipalId,
|
||||
'tenant' => $context['tenant'],
|
||||
'method' => 'GET',
|
||||
'full_path' => $assignmentsEndpoint,
|
||||
'full_path' => $this->buildFullPath($assignmentsEndpoint),
|
||||
'client_request_id' => $clientRequestId,
|
||||
]);
|
||||
|
||||
@ -545,29 +587,68 @@ 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
|
||||
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
|
||||
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
|
||||
$graphSps = $graphSpResponse->json('value', []);
|
||||
$appRoles = $graphSps[0]['appRoles'] ?? [];
|
||||
$graphSpEndpoint = $this->contracts->probePath(
|
||||
'service_principal_by_app_id',
|
||||
['{appId}' => '00000003-0000-0000-c000-000000000000'],
|
||||
);
|
||||
|
||||
$graphSpResponse = null;
|
||||
|
||||
if (is_string($graphSpEndpoint) && $graphSpEndpoint !== '') {
|
||||
$graphSpResponse = $this->send('GET', ltrim($graphSpEndpoint, '/'), [], $context);
|
||||
}
|
||||
|
||||
$graphSps = $graphSpResponse instanceof Response
|
||||
? $graphSpResponse->json('value', [])
|
||||
: [];
|
||||
$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,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class AuditLogger
|
||||
@ -22,6 +23,10 @@ public function log(
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'actor_id' => $actorId,
|
||||
@ -31,7 +36,7 @@ public function log(
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $metadata + $context,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -40,27 +40,79 @@ public function getGrantedPermissions(Tenant $tenant): array
|
||||
* @param bool $persist Persist comparison results to tenant_permissions
|
||||
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
||||
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
||||
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
|
||||
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
|
||||
* @return array{
|
||||
* overall_status:string,
|
||||
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
||||
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
||||
* }
|
||||
*/
|
||||
public function compare(
|
||||
Tenant $tenant,
|
||||
?array $grantedStatuses = null,
|
||||
bool $persist = true,
|
||||
bool $liveCheck = false,
|
||||
bool $useConfiguredStub = true
|
||||
bool $useConfiguredStub = true,
|
||||
?array $graphOptions = null,
|
||||
): array {
|
||||
$required = $this->getRequiredPermissions();
|
||||
$liveCheckMeta = [
|
||||
'attempted' => false,
|
||||
'succeeded' => false,
|
||||
'http_status' => null,
|
||||
'reason_code' => null,
|
||||
];
|
||||
|
||||
$liveCheckFailed = false;
|
||||
$liveCheckDetails = null;
|
||||
|
||||
// If liveCheck is requested, fetch actual permissions from Graph
|
||||
if ($liveCheck && $grantedStatuses === null) {
|
||||
$grantedStatuses = $this->fetchLivePermissions($tenant);
|
||||
$liveCheckMeta['attempted'] = true;
|
||||
|
||||
$appId = null;
|
||||
if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
|
||||
$appId = (string) $graphOptions['client_id'];
|
||||
} elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') {
|
||||
$appId = (string) $tenant->graphOptions()['client_id'];
|
||||
}
|
||||
|
||||
if ($appId !== null) {
|
||||
$liveCheckMeta['app_id'] = $appId;
|
||||
}
|
||||
|
||||
$grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions);
|
||||
|
||||
if (isset($grantedStatuses['__error'])) {
|
||||
$liveCheckFailed = true;
|
||||
$liveCheckDetails = $grantedStatuses['__error']['details'] ?? null;
|
||||
$liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null;
|
||||
$liveCheckDetails = is_array($liveCheckError['details'] ?? null)
|
||||
? $liveCheckError['details']
|
||||
: (is_array($liveCheckError) ? $liveCheckError : null);
|
||||
|
||||
$httpStatus = $liveCheckDetails['status'] ?? null;
|
||||
$liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null;
|
||||
$liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode(
|
||||
$liveCheckMeta['http_status'],
|
||||
is_array($liveCheckDetails) ? $liveCheckDetails : null,
|
||||
);
|
||||
|
||||
unset($grantedStatuses['__error']);
|
||||
$grantedStatuses = null;
|
||||
} else {
|
||||
$observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0;
|
||||
$liveCheckMeta['observed_permissions_count'] = $observedCount;
|
||||
|
||||
if ($observedCount === 0) {
|
||||
// Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh.
|
||||
// This prevents false "missing" findings due to partial/misconfigured verification context.
|
||||
$liveCheckMeta['succeeded'] = false;
|
||||
$liveCheckMeta['reason_code'] = 'permissions_inventory_empty';
|
||||
$grantedStatuses = null;
|
||||
} else {
|
||||
$liveCheckMeta['succeeded'] = true;
|
||||
$liveCheckMeta['reason_code'] = 'ok';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,16 +133,29 @@ public function compare(
|
||||
$hasErrors = false;
|
||||
$checkedAt = now();
|
||||
|
||||
$canPersist = $persist;
|
||||
|
||||
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||
$canPersist = false;
|
||||
}
|
||||
|
||||
foreach ($required as $permission) {
|
||||
$key = $permission['key'];
|
||||
$status = $liveCheckFailed
|
||||
? 'error'
|
||||
: ($granted[$key]['status'] ?? 'missing');
|
||||
|
||||
$details = $liveCheckFailed
|
||||
? ($liveCheckDetails ?? ['source' => 'graph_api'])
|
||||
? array_filter([
|
||||
'source' => 'graph_api',
|
||||
'status' => $liveCheckMeta['http_status'],
|
||||
'reason_code' => $liveCheckMeta['reason_code'],
|
||||
'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null,
|
||||
], fn (mixed $value): bool => $value !== null)
|
||||
: ($granted[$key]['details'] ?? null);
|
||||
|
||||
if ($persist) {
|
||||
if ($canPersist) {
|
||||
TenantPermission::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@ -123,10 +188,36 @@ public function compare(
|
||||
default => 'granted',
|
||||
};
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'overall_status' => $overall,
|
||||
'permissions' => $results,
|
||||
];
|
||||
|
||||
if ($liveCheckMeta['attempted'] === true) {
|
||||
$payload['live_check'] = $liveCheckMeta;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $details
|
||||
*/
|
||||
private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string
|
||||
{
|
||||
if (is_array($details) && is_string($details['reason_code'] ?? null)) {
|
||||
return (string) $details['reason_code'];
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$httpStatus === 401 => 'authentication_failed',
|
||||
$httpStatus === 403 => 'permission_denied',
|
||||
$httpStatus === 408 => 'dependency_unreachable',
|
||||
$httpStatus === 429 => 'throttled',
|
||||
is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable',
|
||||
is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error',
|
||||
default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -211,11 +302,11 @@ private function configuredGrantedKeys(): array
|
||||
*
|
||||
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
||||
*/
|
||||
private function fetchLivePermissions(Tenant $tenant): array
|
||||
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
|
||||
{
|
||||
try {
|
||||
$response = $this->graphClient->getServicePrincipalPermissions(
|
||||
$tenant->graphOptions()
|
||||
$graphOptions ?? $tenant->graphOptions()
|
||||
);
|
||||
|
||||
if (! $response->success) {
|
||||
@ -232,6 +323,25 @@ private function fetchLivePermissions(Tenant $tenant): array
|
||||
}
|
||||
|
||||
$grantedPermissions = $response->data['permissions'] ?? [];
|
||||
$diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null;
|
||||
$assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0;
|
||||
$mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null;
|
||||
|
||||
if ($assignmentsTotal > 0 && $mappedTotal === 0) {
|
||||
return [
|
||||
'__error' => [
|
||||
'status' => 'error',
|
||||
'details' => [
|
||||
'source' => 'graph_api',
|
||||
'status' => $response->status,
|
||||
'reason_code' => 'permission_mapping_failed',
|
||||
'message' => 'Graph returned app role assignments, but the system could not map them to permission values.',
|
||||
'diagnostics' => $diagnostics,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($grantedPermissions as $permission) {
|
||||
|
||||
@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
class TenantRequiredPermissionsViewModelBuilder
|
||||
{
|
||||
/**
|
||||
* @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
|
||||
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
|
||||
* @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
|
||||
* @phpstan-type ViewModel array{
|
||||
* tenant: array{id:int,external_id:string,name:string},
|
||||
* overview: array{
|
||||
* overall: string,
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* feature_impacts: array<int, FeatureImpact>
|
||||
* },
|
||||
* permissions: array<int, TenantPermissionRow>,
|
||||
* filters: FilterState,
|
||||
* copy: array{application:string,delegated:string}
|
||||
* }
|
||||
*/
|
||||
public function __construct(private readonly TenantPermissionService $permissionService) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return ViewModel
|
||||
*/
|
||||
public function build(Tenant $tenant, array $filters = []): array
|
||||
{
|
||||
$comparison = $this->permissionService->compare(
|
||||
$tenant,
|
||||
persist: false,
|
||||
liveCheck: false,
|
||||
useConfiguredStub: false,
|
||||
);
|
||||
|
||||
/** @var array<int, TenantPermissionRow> $allPermissions */
|
||||
$allPermissions = collect($comparison['permissions'] ?? [])
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$state = self::normalizeFilterState($filters);
|
||||
|
||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||
|
||||
return [
|
||||
'tenant' => [
|
||||
'id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) $tenant->external_id,
|
||||
'name' => (string) $tenant->name,
|
||||
],
|
||||
'overview' => [
|
||||
'overall' => self::deriveOverallStatus($allPermissions),
|
||||
'counts' => self::deriveCounts($allPermissions),
|
||||
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||
],
|
||||
'permissions' => $filteredPermissions,
|
||||
'filters' => $state,
|
||||
'copy' => [
|
||||
'application' => self::deriveCopyPayload($allPermissions, 'application', $state['features']),
|
||||
'delegated' => self::deriveCopyPayload($allPermissions, 'delegated', $state['features']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
*/
|
||||
public static function deriveOverallStatus(array $permissions): string
|
||||
{
|
||||
$hasMissingApplication = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||
);
|
||||
|
||||
if ($hasMissingApplication) {
|
||||
return VerificationReportOverall::Blocked->value;
|
||||
}
|
||||
|
||||
$hasErrors = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'error',
|
||||
);
|
||||
|
||||
$hasMissingDelegated = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||
);
|
||||
|
||||
if ($hasErrors || $hasMissingDelegated) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
return VerificationReportOverall::Ready->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
||||
*/
|
||||
public static function deriveCounts(array $permissions): array
|
||||
{
|
||||
$counts = [
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 0,
|
||||
'error' => 0,
|
||||
];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$counts['missing_delegated'] += 1;
|
||||
} else {
|
||||
$counts['missing_application'] += 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'granted') {
|
||||
$counts['present'] += 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'error') {
|
||||
$counts['error'] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, FeatureImpact>
|
||||
*/
|
||||
public static function deriveFeatureImpacts(array $permissions): array
|
||||
{
|
||||
/** @var array<string, FeatureImpact> $impacts */
|
||||
$impacts = [];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
$features = array_values(array_unique($row['features'] ?? []));
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if (! isset($impacts[$feature])) {
|
||||
$impacts[$feature] = [
|
||||
'feature' => $feature,
|
||||
'missing' => 0,
|
||||
'required_application' => 0,
|
||||
'required_delegated' => 0,
|
||||
'blocked' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$impacts[$feature]['required_delegated'] += 1;
|
||||
} else {
|
||||
$impacts[$feature]['required_application'] += 1;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
$impacts[$feature]['missing'] += 1;
|
||||
|
||||
if (($row['type'] ?? null) === 'application') {
|
||||
$impacts[$feature]['blocked'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$values = array_values($impacts);
|
||||
|
||||
usort($values, static function (array $a, array $b): int {
|
||||
$blocked = (int) ($b['blocked'] <=> $a['blocked']);
|
||||
if ($blocked !== 0) {
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
$missing = (int) (($b['missing'] ?? 0) <=> ($a['missing'] ?? 0));
|
||||
if ($missing !== 0) {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['feature'] ?? ''), (string) ($b['feature'] ?? ''));
|
||||
});
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy payload semantics:
|
||||
* - Always Missing-only
|
||||
* - Always Type fixed by button (application vs delegated)
|
||||
* - Respects Feature filter only
|
||||
* - Ignores Search
|
||||
*
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
* @param array<int, string> $featureFilter
|
||||
*/
|
||||
public static function deriveCopyPayload(array $permissions, string $type, array $featureFilter = []): string
|
||||
{
|
||||
$featureFilter = array_values(array_unique(array_filter(array_map('strval', $featureFilter))));
|
||||
|
||||
$payload = collect($permissions)
|
||||
->filter(function (array $row) use ($type, $featureFilter): bool {
|
||||
if (($row['status'] ?? null) !== 'missing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($featureFilter === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($featureFilter, $rowFeatures)) > 0;
|
||||
})
|
||||
->pluck('key')
|
||||
->map(fn (mixed $key): string => (string) $key)
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return implode("\n", $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, TenantPermissionRow>
|
||||
*/
|
||||
public static function applyFilterState(array $permissions, array $state): array
|
||||
{
|
||||
$status = $state['status'] ?? 'missing';
|
||||
$type = $state['type'] ?? 'all';
|
||||
$features = $state['features'] ?? [];
|
||||
$search = $state['search'] ?? '';
|
||||
|
||||
$search = is_string($search) ? trim($search) : '';
|
||||
$searchLower = strtolower($search);
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
$filtered = collect($permissions)
|
||||
->filter(function (array $row) use ($status, $type, $features): bool {
|
||||
$rowStatus = $row['status'] ?? null;
|
||||
$rowType = $row['type'] ?? null;
|
||||
|
||||
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status === 'present' && $rowStatus !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($type !== 'all' && $rowType !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($features === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($features, $rowFeatures)) > 0;
|
||||
})
|
||||
->when($searchLower !== '', function ($collection) use ($searchLower) {
|
||||
return $collection->filter(function (array $row) use ($searchLower): bool {
|
||||
$key = strtolower((string) ($row['key'] ?? ''));
|
||||
$description = strtolower((string) ($row['description'] ?? ''));
|
||||
|
||||
return str_contains($key, $searchLower) || ($description !== '' && str_contains($description, $searchLower));
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
usort($filtered, static function (array $a, array $b): int {
|
||||
$weight = static function (array $row): int {
|
||||
return match ($row['status'] ?? null) {
|
||||
'missing' => 0,
|
||||
'error' => 1,
|
||||
default => 2,
|
||||
};
|
||||
};
|
||||
|
||||
$cmp = $weight($a) <=> $weight($b);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
});
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return FilterState
|
||||
*/
|
||||
public static function normalizeFilterState(array $filters): array
|
||||
{
|
||||
$status = (string) ($filters['status'] ?? 'missing');
|
||||
$type = (string) ($filters['type'] ?? 'all');
|
||||
$features = $filters['features'] ?? [];
|
||||
$search = (string) ($filters['search'] ?? '');
|
||||
|
||||
if (! in_array($status, ['missing', 'present', 'all'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! in_array($type, ['application', 'delegated', 'all'], true)) {
|
||||
$type = 'all';
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'type' => $type,
|
||||
'features' => $features,
|
||||
'search' => $search,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return TenantPermissionRow
|
||||
*/
|
||||
private static function normalizePermissionRow(array $row): array
|
||||
{
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
$type = (string) ($row['type'] ?? 'application');
|
||||
$description = $row['description'] ?? null;
|
||||
$features = $row['features'] ?? [];
|
||||
$status = (string) ($row['status'] ?? 'missing');
|
||||
$details = $row['details'] ?? null;
|
||||
|
||||
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||
$type = 'application';
|
||||
}
|
||||
|
||||
if (! is_string($description) || $description === '') {
|
||||
$description = null;
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! is_array($details)) {
|
||||
$details = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'features' => $features,
|
||||
'status' => $status,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||
use App\Services\Operations\BulkIdempotencyFingerprint;
|
||||
@ -60,12 +61,19 @@ public function ensureRun(
|
||||
array $inputs,
|
||||
?User $initiator = null
|
||||
): OperationRun {
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
|
||||
}
|
||||
|
||||
$hash = $this->calculateHash($tenant->id, $type, $inputs);
|
||||
|
||||
// Idempotency Check (Fast Path)
|
||||
// We check specific status to match the partial unique index
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
@ -78,6 +86,7 @@ public function ensureRun(
|
||||
// Create new run (race-safe via partial unique index)
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
@ -97,6 +106,7 @@ public function ensureRun(
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
@ -116,12 +126,19 @@ public function ensureRunWithIdentity(
|
||||
array $context,
|
||||
?User $initiator = null
|
||||
): OperationRun {
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
|
||||
}
|
||||
|
||||
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
||||
|
||||
// Idempotency Check (Fast Path)
|
||||
// We check specific status to match the partial unique index
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
@ -134,6 +151,7 @@ public function ensureRunWithIdentity(
|
||||
// Create new run (race-safe via partial unique index)
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
@ -153,6 +171,7 @@ public function ensureRunWithIdentity(
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
@ -227,6 +246,59 @@ public function enqueueBulkOperation(
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function ensureWorkspaceRunWithIdentity(
|
||||
Workspace $workspace,
|
||||
string $type,
|
||||
array $identityInputs,
|
||||
array $context,
|
||||
?User $initiator = null,
|
||||
): OperationRun {
|
||||
$hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs);
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereNull('tenant_id')
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
'type' => $type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => $hash,
|
||||
'context' => $context,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereNull('tenant_id')
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateRun(
|
||||
OperationRun $run,
|
||||
string $status,
|
||||
@ -518,6 +590,15 @@ protected function calculateHash(int $tenantId, string $type, array $inputs): st
|
||||
return hash('sha256', $tenantId.'|'.$type.'|'.$json);
|
||||
}
|
||||
|
||||
protected function calculateWorkspaceHash(int $workspaceId, string $type, array $inputs): string
|
||||
{
|
||||
$normalizedInputs = $this->normalizeInputs($inputs);
|
||||
|
||||
$json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize inputs for stable identity hashing.
|
||||
*
|
||||
|
||||
@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
57
app/Services/Verification/StartVerification.php
Normal file
57
app/Services/Verification/StartVerification.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class StartVerification
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderOperationStartGate $providers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start (or dedupe) a provider-connection verification run.
|
||||
*
|
||||
* @param array<string, mixed> $extraContext
|
||||
*/
|
||||
public function providerConnectionCheck(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
User $initiator,
|
||||
array $extraContext = [],
|
||||
): ProviderOperationStartResult {
|
||||
if (! $initiator->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
return $this->providers->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: $extraContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,4 +22,12 @@ enum AuditActionId: string
|
||||
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
}
|
||||
|
||||
66
app/Support/Audit/AuditContextSanitizer.php
Normal file
66
app/Support/Audit/AuditContextSanitizer.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
final class AuditContextSanitizer
|
||||
{
|
||||
private const REDACTED = '[REDACTED]';
|
||||
|
||||
public static function sanitize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && self::shouldRedactKey($key)) {
|
||||
$sanitized[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$key] = self::sanitize($item);
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return self::sanitizeString($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function shouldRedactKey(string $key): bool
|
||||
{
|
||||
$key = strtolower(trim($key));
|
||||
|
||||
return str_contains($key, 'token')
|
||||
|| str_contains($key, 'secret')
|
||||
|| str_contains($key, 'password')
|
||||
|| str_contains($key, 'authorization')
|
||||
|| str_contains($key, 'private_key')
|
||||
|| str_contains($key, 'client_secret');
|
||||
}
|
||||
|
||||
private static function sanitizeString(string $value): string
|
||||
{
|
||||
$candidate = trim($value);
|
||||
|
||||
if ($candidate === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,25 @@ class Capabilities
|
||||
|
||||
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
||||
|
||||
// Managed tenant onboarding
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY = 'workspace_managed_tenant.onboard.identify';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW = 'workspace_managed_tenant.onboard.connection.view';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE = 'workspace_managed_tenant.onboard.connection.manage';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START = 'workspace_managed_tenant.onboard.verification.start';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.inventory_sync';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.policy_sync';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP = 'workspace_managed_tenant.onboard.bootstrap.backup_bootstrap';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE = 'workspace_managed_tenant.onboard.activate';
|
||||
|
||||
// Tenants
|
||||
public const TENANT_VIEW = 'tenant.view';
|
||||
|
||||
@ -42,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';
|
||||
|
||||
|
||||
@ -36,6 +36,10 @@ final class BadgeCatalog
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -28,4 +28,8 @@ enum BadgeDomain: string
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
case ProviderConnectionStatus = 'provider_connection.status';
|
||||
case ProviderConnectionHealth = 'provider_connection.health';
|
||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
||||
case VerificationCheckStatus = 'verification_check_status';
|
||||
case VerificationCheckSeverity = 'verification_check_severity';
|
||||
case VerificationReportOverall = 'verification_report_overall';
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ManagedTenantOnboardingVerificationStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_started' => new BadgeSpec('Not started', 'gray', 'heroicon-m-minus-circle'),
|
||||
'in_progress' => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
||||
'needs_attention' => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||
'ready' => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckSeverity;
|
||||
|
||||
final class VerificationCheckSeverityBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
|
||||
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
|
||||
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
|
||||
final class VerificationCheckStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
final class VerificationReportOverallBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,23 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
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) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$user = $request->user();
|
||||
|
||||
|
||||
@ -21,6 +21,13 @@ public static function index(Tenant $tenant): string
|
||||
return OperationRunResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
public static function tenantlessView(OperationRun|int $run): string
|
||||
{
|
||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
||||
|
||||
return route('admin.operations.view', ['run' => $runId]);
|
||||
}
|
||||
|
||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||
{
|
||||
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
|
||||
|
||||
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
|
||||
*
|
||||
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
|
||||
* + WorkspaceCapabilityResolver.
|
||||
*
|
||||
* Rules:
|
||||
* - Non-member → hidden UI + 404 server-side
|
||||
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||
* - Member with capability → enabled
|
||||
*/
|
||||
final class WorkspaceUiEnforcement
|
||||
{
|
||||
private Action $action;
|
||||
|
||||
private bool $requireMembership = true;
|
||||
|
||||
private ?string $capability = null;
|
||||
|
||||
private bool $isDestructive = false;
|
||||
|
||||
private ?string $customTooltip = null;
|
||||
|
||||
private Model|Closure|null $record = null;
|
||||
|
||||
private function __construct(Action $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a table action.
|
||||
*
|
||||
* @param Action $action The Filament action to wrap
|
||||
* @param Model|Closure $record The owner record or a closure that returns it
|
||||
*/
|
||||
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->record = $record;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function requireMembership(bool $require = true): self
|
||||
{
|
||||
$this->requireMembership = $require;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||
*/
|
||||
public function requireCapability(string $capability): self
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||
);
|
||||
}
|
||||
|
||||
$this->capability = $capability;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function destructive(): self
|
||||
{
|
||||
$this->isDestructive = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tooltip(string $message): self
|
||||
{
|
||||
$this->customTooltip = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function apply(): Action
|
||||
{
|
||||
$this->applyVisibility();
|
||||
$this->applyDisabledState();
|
||||
$this->applyDestructiveConfirmation();
|
||||
$this->applyServerSideGuard();
|
||||
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
private function applyVisibility(): void
|
||||
{
|
||||
if (! $this->requireMembership) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->visible(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
return $context->isMember;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDisabledState(): void
|
||||
{
|
||||
if ($this->capability === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$this->action->disabled(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if (! $context->isMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $context->hasCapability;
|
||||
});
|
||||
|
||||
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
return $tooltip;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDestructiveConfirmation(): void
|
||||
{
|
||||
if (! $this->isDestructive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->requiresConfirmation();
|
||||
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||
}
|
||||
|
||||
private function applyServerSideGuard(): void
|
||||
{
|
||||
$this->action->before(function (?Model $record = null): void {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->shouldDenyAsNotFound()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($context->shouldDenyAsForbidden()) {
|
||||
abort(403);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->resolveWorkspaceWithRecord($record);
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return new WorkspaceAccessContext(
|
||||
user: null,
|
||||
workspace: null,
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$isMember = $resolver->isMember($user, $workspace);
|
||||
|
||||
$hasCapability = true;
|
||||
if ($this->capability !== null && $isMember) {
|
||||
$hasCapability = $resolver->can($user, $workspace, $this->capability);
|
||||
}
|
||||
|
||||
return new WorkspaceAccessContext(
|
||||
user: $user,
|
||||
workspace: $workspace,
|
||||
isMember: $isMember,
|
||||
hasCapability: $hasCapability,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
|
||||
{
|
||||
if ($record instanceof Workspace) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
if ($this->record !== null) {
|
||||
try {
|
||||
$resolved = $this->record instanceof Closure
|
||||
? ($this->record)()
|
||||
: $this->record;
|
||||
|
||||
if ($resolved instanceof Workspace) {
|
||||
return $resolved;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckSeverity: string
|
||||
{
|
||||
case Info = 'info';
|
||||
case Low = 'low';
|
||||
case Medium = 'medium';
|
||||
case High = 'high';
|
||||
case Critical = 'critical';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckStatus: string
|
||||
{
|
||||
case Pass = 'pass';
|
||||
case Fail = 'fail';
|
||||
case Warn = 'warn';
|
||||
case Skip = 'skip';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
19
app/Support/Verification/VerificationReportOverall.php
Normal file
19
app/Support/Verification/VerificationReportOverall.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationReportOverall: string
|
||||
{
|
||||
case Ready = 'ready';
|
||||
case NeedsAttention = 'needs_attention';
|
||||
case Blocked = 'blocked';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
421
app/Support/Verification/VerificationReportSanitizer.php
Normal file
421
app/Support/Verification/VerificationReportSanitizer.php
Normal file
@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportSanitizer
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const FORBIDDEN_KEY_SUBSTRINGS = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'client_secret',
|
||||
'authorization',
|
||||
'password',
|
||||
'cookie',
|
||||
'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>
|
||||
*/
|
||||
public static function sanitizeReport(array $report): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
|
||||
if ($schemaVersion !== null) {
|
||||
$sanitized['schema_version'] = $schemaVersion;
|
||||
}
|
||||
|
||||
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
|
||||
if ($flow !== null) {
|
||||
$sanitized['flow'] = $flow;
|
||||
}
|
||||
|
||||
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
|
||||
if ($generatedAt !== null) {
|
||||
$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']);
|
||||
|
||||
if ($identity !== []) {
|
||||
$sanitized['identity'] = $identity;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
|
||||
$summary = self::sanitizeSummary($summary);
|
||||
|
||||
if ($summary !== null) {
|
||||
$sanitized['summary'] = $summary;
|
||||
}
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
|
||||
$checks = self::sanitizeChecks($checks);
|
||||
|
||||
if ($checks !== null) {
|
||||
$sanitized['checks'] = $checks;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private static function sanitizeIdentity(array $identity): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($identity as $key => $value) {
|
||||
if (! is_string($key) || trim($key) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[$key] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = self::sanitizeValueString($value);
|
||||
|
||||
if ($value !== null) {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
||||
*/
|
||||
private static function sanitizeSummary(array $summary): ?array
|
||||
{
|
||||
$overall = $summary['overall'] ?? null;
|
||||
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'overall' => $overall,
|
||||
'counts' => [
|
||||
'total' => $counts['total'],
|
||||
'pass' => $counts['pass'],
|
||||
'fail' => $counts['fail'],
|
||||
'warn' => $counts['warn'],
|
||||
'skip' => $counts['skip'],
|
||||
'running' => $counts['running'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $checks
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private static function sanitizeChecks(array $checks): ?array
|
||||
{
|
||||
if ($checks === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
|
||||
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
|
||||
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
|
||||
|
||||
if ($key === null || $title === null || $reasonCode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severityRaw = $check['severity'] ?? null;
|
||||
if (! is_string($severityRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severityRaw));
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messageRaw = $check['message'] ?? null;
|
||||
if (! is_string($messageRaw) || trim($messageRaw) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$sanitized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
'severity' => $severity,
|
||||
'blocking' => $blocking,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => self::sanitizeMessage($messageRaw),
|
||||
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
||||
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $evidence
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
*/
|
||||
private static function sanitizeEvidence(array $evidence): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
if (! is_string($kind) || trim($kind) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = trim($kind);
|
||||
|
||||
if (! in_array($kind, self::ALLOWED_EVIDENCE_KINDS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $value];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitizedValue = self::sanitizeValueString($value);
|
||||
|
||||
if ($sanitizedValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $nextSteps
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
private static function sanitizeNextSteps(array $nextSteps): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
||||
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private static function sanitizeMessage(mixed $message): string
|
||||
{
|
||||
if (! is_string($message)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
|
||||
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
|
||||
$message = str_ireplace(
|
||||
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
|
||||
'[REDACTED]',
|
||||
$message,
|
||||
);
|
||||
|
||||
$message = trim($message);
|
||||
|
||||
return $message === '' ? '—' : substr($message, 0, 240);
|
||||
}
|
||||
|
||||
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return substr($value, 0, 200);
|
||||
}
|
||||
|
||||
private static function sanitizeValueString(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($value) > 512) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lower = strtolower($value);
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function containsForbiddenKeySubstring(string $value): bool
|
||||
{
|
||||
$lower = strtolower($value);
|
||||
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
257
app/Support/Verification/VerificationReportSchema.php
Normal file
257
app/Support/Verification/VerificationReportSchema.php
Normal file
@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class VerificationReportSchema
|
||||
{
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function normalizeReport(mixed $report): ?array
|
||||
{
|
||||
if (! is_array($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! self::isValidReport($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function isValidReport(array $report): bool
|
||||
{
|
||||
$schemaVersion = self::schemaVersion($report);
|
||||
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($report['flow'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
if (! is_array($summary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$overall = $summary['overall'] ?? null;
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
if (! is_array($counts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
if (! is_array($checks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check) || ! self::isValidCheckResult($check)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function schemaVersion(array $report): ?string
|
||||
{
|
||||
$candidate = $report['schema_version'] ?? null;
|
||||
|
||||
if (! is_string($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = trim($candidate);
|
||||
|
||||
if ($candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public static function isSupportedSchemaVersion(string $schemaVersion): bool
|
||||
{
|
||||
$parts = explode('.', $schemaVersion, 3);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$major = (int) $parts[0];
|
||||
|
||||
return $major === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
*/
|
||||
private static function isValidCheckResult(array $check): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($check['key'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['title'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = trim($severity);
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_bool($check['blocking'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['message'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$evidence = $check['evidence'] ?? null;
|
||||
if (! is_array($evidence)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? null;
|
||||
if (! is_array($nextSteps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step) || ! self::isValidNextStep($step)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pointer
|
||||
*/
|
||||
private static function isValidEvidencePointer(array $pointer): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
return is_int($value) || self::isNonEmptyString($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $step
|
||||
*/
|
||||
private static function isValidNextStep(array $step): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($step['label'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($step['url'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function isNonEmptyString(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && trim($value) !== '';
|
||||
}
|
||||
|
||||
private static function isNonNegativeInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value) && $value >= 0;
|
||||
}
|
||||
|
||||
private static function isIsoDateTimeString(mixed $value): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new DateTimeImmutable((string) $value);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
347
app/Support/Verification/VerificationReportWriter.php
Normal file
347
app/Support/Verification/VerificationReportWriter.php
Normal file
@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
|
||||
final class VerificationReportWriter
|
||||
{
|
||||
/**
|
||||
* Baseline reason code taxonomy (v1).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const array BASELINE_REASON_CODES = [
|
||||
'ok',
|
||||
'not_applicable',
|
||||
'missing_configuration',
|
||||
'permission_denied',
|
||||
'authentication_failed',
|
||||
'throttled',
|
||||
'dependency_unreachable',
|
||||
'invalid_state',
|
||||
'unknown_error',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function write(OperationRun $run, array $checks, array $identity = []): array
|
||||
{
|
||||
$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)) {
|
||||
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['verification_report'] = $report;
|
||||
|
||||
$run->update(['context' => $context]);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(string $flow, array $checks, array $identity = []): array
|
||||
{
|
||||
$flow = trim($flow);
|
||||
$flow = $flow !== '' ? $flow : 'unknown';
|
||||
|
||||
$normalizedChecks = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedChecks[] = self::normalizeCheckResult($check);
|
||||
}
|
||||
|
||||
$counts = self::deriveCounts($normalizedChecks);
|
||||
|
||||
$report = [
|
||||
'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,
|
||||
],
|
||||
'checks' => $normalizedChecks,
|
||||
];
|
||||
|
||||
if ($identity !== []) {
|
||||
$report['identity'] = $identity;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildFallbackReport(string $flow): array
|
||||
{
|
||||
return [
|
||||
'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' => [
|
||||
'total' => 0,
|
||||
'pass' => 0,
|
||||
'fail' => 0,
|
||||
'warn' => 0,
|
||||
'skip' => 0,
|
||||
'running' => 0,
|
||||
],
|
||||
],
|
||||
'checks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
* @return array{
|
||||
* key: string,
|
||||
* title: string,
|
||||
* status: string,
|
||||
* severity: string,
|
||||
* blocking: bool,
|
||||
* reason_code: string,
|
||||
* message: string,
|
||||
* evidence: array<int, array{kind: string, value: int|string}>,
|
||||
* next_steps: array<int, array{label: string, url: string}>
|
||||
* }
|
||||
*/
|
||||
private static function normalizeCheckResult(array $check): array
|
||||
{
|
||||
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
|
||||
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => self::normalizeCheckStatus($check['status'] ?? null),
|
||||
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
|
||||
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
|
||||
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
|
||||
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
|
||||
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
|
||||
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeCheckStatus(mixed $status): string
|
||||
{
|
||||
if (! is_string($status)) {
|
||||
return VerificationCheckStatus::Fail->value;
|
||||
}
|
||||
|
||||
$status = strtolower(trim($status));
|
||||
|
||||
return in_array($status, VerificationCheckStatus::values(), true)
|
||||
? $status
|
||||
: VerificationCheckStatus::Fail->value;
|
||||
}
|
||||
|
||||
private static function normalizeCheckSeverity(mixed $severity): string
|
||||
{
|
||||
if (! is_string($severity)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severity));
|
||||
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
|
||||
}
|
||||
|
||||
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||
{
|
||||
if (! is_string($reasonCode)) {
|
||||
return 'unknown_error';
|
||||
}
|
||||
|
||||
$reasonCode = strtolower(trim($reasonCode));
|
||||
|
||||
if ($reasonCode === '') {
|
||||
return 'unknown_error';
|
||||
}
|
||||
|
||||
if (str_starts_with($reasonCode, 'ext.')) {
|
||||
return $reasonCode;
|
||||
}
|
||||
|
||||
$reasonCode = match ($reasonCode) {
|
||||
'graph_throttled' => 'throttled',
|
||||
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
|
||||
'provider_auth_failed' => 'authentication_failed',
|
||||
'validation_error', 'conflict_detected' => 'invalid_state',
|
||||
'unknown' => 'unknown_error',
|
||||
default => $reasonCode,
|
||||
};
|
||||
|
||||
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
*/
|
||||
private static function normalizeEvidence(mixed $evidence): array
|
||||
{
|
||||
if (! is_array($evidence)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if ($kind === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_int($value) && ! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'kind' => $kind,
|
||||
'value' => is_int($value) ? $value : trim($value),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
private static function normalizeNextSteps(mixed $steps): array
|
||||
{
|
||||
if (! is_array($steps)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
|
||||
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
|
||||
*/
|
||||
private static function deriveCounts(array $checks): array
|
||||
{
|
||||
$counts = [
|
||||
'total' => count($checks),
|
||||
'pass' => 0,
|
||||
'fail' => 0,
|
||||
'warn' => 0,
|
||||
'skip' => 0,
|
||||
'running' => 0,
|
||||
];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
if (! is_string($status) || ! array_key_exists($status, $counts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$status] += 1;
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
|
||||
*/
|
||||
private static function deriveOverall(array $checks, array $counts): string
|
||||
{
|
||||
if (($counts['running'] ?? 0) > 0) {
|
||||
return VerificationReportOverall::Running->value;
|
||||
}
|
||||
|
||||
if (($counts['total'] ?? 0) === 0) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
|
||||
return VerificationReportOverall::Blocked->value;
|
||||
}
|
||||
}
|
||||
|
||||
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
return VerificationReportOverall::Ready->value;
|
||||
}
|
||||
|
||||
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,17 @@
|
||||
| and drift checks.
|
||||
|
|
||||
*/
|
||||
'probes' => [
|
||||
'organization' => [
|
||||
'path' => 'organization',
|
||||
],
|
||||
'service_principal_by_app_id' => [
|
||||
'path' => "servicePrincipals?\$filter=appId eq '{appId}'",
|
||||
],
|
||||
'service_principal_app_role_assignments' => [
|
||||
'path' => 'servicePrincipals/{servicePrincipalId}/appRoleAssignments',
|
||||
],
|
||||
],
|
||||
'types' => [
|
||||
'directoryGroups' => [
|
||||
'resource' => 'groups',
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -20,7 +21,29 @@ class OperationRunFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
if ($tenant->workspace_id === null) {
|
||||
$workspaceId = (int) Workspace::factory()->create()->getKey();
|
||||
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||
|
||||
return $workspaceId;
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'user_id' => User::factory(),
|
||||
'initiator_name' => fake()->name(),
|
||||
'type' => fake()->randomElement(OperationRunType::values()),
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -16,7 +17,29 @@ class ProviderConnectionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
if ($tenant->workspace_id === null) {
|
||||
$workspaceId = (int) Workspace::factory()->create()->getKey();
|
||||
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||
|
||||
return $workspaceId;
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'display_name' => fake()->company(),
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -9,6 +11,21 @@
|
||||
*/
|
||||
class TenantFactory extends Factory
|
||||
{
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterCreating(function (Tenant $tenant): void {
|
||||
if ($tenant->workspace_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$tenant->forceFill([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
|
||||
@ -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,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
$table->index(['tenant_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('managed_tenant_onboarding_sessions');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'workspace_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$tenantIds = DB::table('tenants')->whereNull('workspace_id')->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$workspaceId = DB::table('tenant_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
||||
->where('tenant_memberships.tenant_id', $tenantId)
|
||||
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
->value('workspace_memberships.workspace_id');
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
DB::table('tenants')
|
||||
->where('id', $tenantId)
|
||||
->update(['workspace_id' => (int) $workspaceId]);
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count();
|
||||
|
||||
if ($remaining === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$legacyWorkspaceId = DB::table('workspaces')->insertGetId([
|
||||
'name' => 'Legacy Workspace',
|
||||
'slug' => 'legacy',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$users = DB::table('tenant_memberships')
|
||||
->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id')
|
||||
->whereNull('tenants.workspace_id')
|
||||
->select([
|
||||
'tenant_memberships.user_id',
|
||||
DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"),
|
||||
])
|
||||
->groupBy('tenant_memberships.user_id')
|
||||
->get();
|
||||
|
||||
$roleFromRank = static fn (int $rank): string => match ($rank) {
|
||||
0 => 'owner',
|
||||
1 => 'manager',
|
||||
2 => 'operator',
|
||||
default => 'readonly',
|
||||
};
|
||||
|
||||
$membershipRows = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$membershipRows[] = [
|
||||
'workspace_id' => (int) $legacyWorkspaceId,
|
||||
'user_id' => (int) $user->user_id,
|
||||
'role' => $roleFromRank((int) $user->role_rank),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($membershipRows !== []) {
|
||||
DB::table('workspace_memberships')->insertOrIgnore($membershipRows);
|
||||
}
|
||||
|
||||
DB::table('tenants')
|
||||
->whereNull('workspace_id')
|
||||
->update(['workspace_id' => (int) $legacyWorkspaceId]);
|
||||
});
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'workspace_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
$table->index(['tenant_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('managed_tenant_onboarding_sessions');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('managed_tenant_onboarding_sessions', 'managed_tenant_onboarding_sessions_old');
|
||||
|
||||
foreach ([
|
||||
'managed_tenant_onboarding_sessions_workspace_id_tenant_id_unique',
|
||||
'managed_tenant_onboarding_sessions_tenant_id_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->string('entra_tenant_id');
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('tenant_id');
|
||||
$table->index('entra_tenant_id');
|
||||
});
|
||||
|
||||
DB::table('managed_tenant_onboarding_sessions_old')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$state = is_string($row->state) ? json_decode($row->state, true) : null;
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$entraTenantId = $row->entra_tenant_id ?? null;
|
||||
|
||||
if (! is_string($entraTenantId) || trim($entraTenantId) === '') {
|
||||
$entraTenantId = $state['entra_tenant_id'] ?? $state['tenant_id'] ?? null;
|
||||
}
|
||||
|
||||
if (! is_string($entraTenantId) || trim($entraTenantId) === '') {
|
||||
$entraTenantId = DB::table('tenants')
|
||||
->where('id', $row->tenant_id)
|
||||
->value('tenant_id');
|
||||
}
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : '';
|
||||
|
||||
if ($entraTenantId === '') {
|
||||
$entraTenantId = sprintf('unknown-%d', (int) $row->id);
|
||||
}
|
||||
|
||||
DB::table('managed_tenant_onboarding_sessions')->insert([
|
||||
'id' => $row->id,
|
||||
'workspace_id' => $row->workspace_id,
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => $row->current_step,
|
||||
'state' => $row->state,
|
||||
'started_by_user_id' => $row->started_by_user_id,
|
||||
'updated_by_user_id' => $row->updated_by_user_id,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('managed_tenant_onboarding_sessions_old');
|
||||
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_workspace_entra_unique ON managed_tenant_onboarding_sessions (workspace_id, entra_tenant_id) WHERE completed_at IS NULL');
|
||||
DB::statement('CREATE UNIQUE INDEX managed_tenant_onboarding_sessions_active_tenant_unique ON managed_tenant_onboarding_sessions (tenant_id) WHERE completed_at IS NULL AND tenant_id IS NOT NULL');
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('managed_tenant_onboarding_sessions', 'entra_tenant_id')) {
|
||||
Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->string('entra_tenant_id')->nullable()->after('tenant_id');
|
||||
});
|
||||
}
|
||||
|
||||
$this->backfillEntraTenantId($driver);
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE managed_tenant_onboarding_sessions ALTER COLUMN tenant_id DROP NOT NULL');
|
||||
DB::statement('ALTER TABLE managed_tenant_onboarding_sessions ALTER COLUMN entra_tenant_id SET NOT NULL');
|
||||
}
|
||||
|
||||
Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->dropUnique(['workspace_id', 'tenant_id']);
|
||||
$table->index('entra_tenant_id');
|
||||
});
|
||||
|
||||
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS managed_tenant_onboarding_sessions_active_workspace_entra_unique ON managed_tenant_onboarding_sessions (workspace_id, entra_tenant_id) WHERE completed_at IS NULL');
|
||||
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS managed_tenant_onboarding_sessions_active_tenant_unique ON managed_tenant_onboarding_sessions (tenant_id) WHERE completed_at IS NULL AND tenant_id IS NOT NULL');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('managed_tenant_onboarding_sessions', 'managed_tenant_onboarding_sessions_new');
|
||||
|
||||
foreach ([
|
||||
'managed_tenant_onboarding_sessions_active_workspace_entra_unique',
|
||||
'managed_tenant_onboarding_sessions_active_tenant_unique',
|
||||
'managed_tenant_onboarding_sessions_tenant_id_index',
|
||||
'managed_tenant_onboarding_sessions_entra_tenant_id_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
$table->index(['tenant_id']);
|
||||
});
|
||||
|
||||
DB::table('managed_tenant_onboarding_sessions_new')
|
||||
->whereNotNull('tenant_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('managed_tenant_onboarding_sessions')->insert([
|
||||
'id' => $row->id,
|
||||
'workspace_id' => $row->workspace_id,
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'current_step' => $row->current_step,
|
||||
'state' => $row->state,
|
||||
'started_by_user_id' => $row->started_by_user_id,
|
||||
'updated_by_user_id' => $row->updated_by_user_id,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('managed_tenant_onboarding_sessions_new');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'managed_tenant_onboarding_sessions_active_workspace_entra_unique',
|
||||
'managed_tenant_onboarding_sessions_active_tenant_unique',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('managed_tenant_onboarding_sessions', 'entra_tenant_id')) {
|
||||
Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->dropIndex(['entra_tenant_id']);
|
||||
$table->dropColumn('entra_tenant_id');
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillEntraTenantId(string $driver): void
|
||||
{
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE managed_tenant_onboarding_sessions
|
||||
SET entra_tenant_id = COALESCE(managed_tenant_onboarding_sessions.entra_tenant_id, tenants.tenant_id, managed_tenant_onboarding_sessions.state->>'tenant_id')
|
||||
FROM tenants
|
||||
WHERE managed_tenant_onboarding_sessions.entra_tenant_id IS NULL
|
||||
AND managed_tenant_onboarding_sessions.tenant_id = tenants.id
|
||||
SQL);
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE managed_tenant_onboarding_sessions
|
||||
SET entra_tenant_id = state->>'tenant_id'
|
||||
WHERE entra_tenant_id IS NULL
|
||||
SQL);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('managed_tenant_onboarding_sessions')
|
||||
->whereNull('entra_tenant_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$state = is_string($row->state) ? json_decode($row->state, true) : null;
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$entraTenantId = $state['entra_tenant_id'] ?? $state['tenant_id'] ?? null;
|
||||
|
||||
if (! is_string($entraTenantId) || trim($entraTenantId) === '') {
|
||||
$entraTenantId = DB::table('tenants')
|
||||
->where('id', $row->tenant_id)
|
||||
->value('tenant_id');
|
||||
}
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : '';
|
||||
|
||||
if ($entraTenantId === '') {
|
||||
$entraTenantId = sprintf('unknown-%d', (int) $row->id);
|
||||
}
|
||||
|
||||
DB::table('managed_tenant_onboarding_sessions')
|
||||
->where('id', $row->id)
|
||||
->update(['entra_tenant_id' => $entraTenantId]);
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_connections')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
if (! Schema::hasColumn('provider_connections', 'workspace_id')) {
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('workspace_id')->nullable()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE provider_connections
|
||||
SET workspace_id = (
|
||||
SELECT tenants.workspace_id
|
||||
FROM tenants
|
||||
WHERE tenants.id = provider_connections.tenant_id
|
||||
)
|
||||
WHERE workspace_id IS NULL
|
||||
SQL);
|
||||
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->index(['workspace_id', 'provider', 'status']);
|
||||
$table->index(['workspace_id', 'provider', 'health_status']);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('provider_connections', 'workspace_id')) {
|
||||
Schema::table('provider_connections', function (Blueprint $table) use ($driver): void {
|
||||
$column = $table->foreignId('workspace_id')->nullable();
|
||||
|
||||
if ($driver !== 'sqlite') {
|
||||
$column->after('id')->constrained('workspaces')->cascadeOnDelete();
|
||||
}
|
||||
|
||||
$table->index('workspace_id');
|
||||
});
|
||||
}
|
||||
|
||||
$this->backfillWorkspaceId($driver);
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE provider_connections ALTER COLUMN workspace_id SET NOT NULL');
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE provider_connections MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
|
||||
}
|
||||
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->index(['workspace_id', 'provider', 'status']);
|
||||
$table->index(['workspace_id', 'provider', 'health_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('provider_connections')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('provider_connections', 'workspace_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->dropIndex(['workspace_id']);
|
||||
$table->dropIndex(['workspace_id', 'provider', 'status']);
|
||||
$table->dropIndex(['workspace_id', 'provider', 'health_status']);
|
||||
$table->dropConstrainedForeignId('workspace_id');
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillWorkspaceId(string $driver): void
|
||||
{
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE provider_connections
|
||||
SET workspace_id = tenants.workspace_id
|
||||
FROM tenants
|
||||
WHERE provider_connections.workspace_id IS NULL
|
||||
AND provider_connections.tenant_id = tenants.id
|
||||
SQL);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('provider_connections')
|
||||
->whereNull('workspace_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$workspaceId = DB::table('tenants')
|
||||
->where('id', $row->tenant_id)
|
||||
->value('workspace_id');
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$workspaceId = DB::table('tenant_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
||||
->where('tenant_memberships.tenant_id', (int) $row->tenant_id)
|
||||
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
->value('workspace_memberships.workspace_id');
|
||||
}
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
DB::table('provider_connections')
|
||||
->where('id', $row->id)
|
||||
->update(['workspace_id' => (int) $workspaceId]);
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('operation_runs', 'operation_runs_old');
|
||||
|
||||
foreach ([
|
||||
'operation_runs_active_unique',
|
||||
'operation_runs_tenant_id_type_created_at_index',
|
||||
'operation_runs_tenant_id_created_at_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('operation_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('initiator_name');
|
||||
$table->string('type');
|
||||
$table->string('status');
|
||||
$table->string('outcome')->default('pending');
|
||||
$table->string('run_identity_hash');
|
||||
$table->json('summary_counts')->default('{}');
|
||||
$table->json('failure_summary')->default('[]');
|
||||
$table->json('context')->default('{}');
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'type', 'created_at']);
|
||||
$table->index(['workspace_id', 'created_at']);
|
||||
$table->index(['tenant_id', 'type', 'created_at']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
});
|
||||
|
||||
DB::table('operation_runs_old')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$workspaceId = DB::table('tenants')
|
||||
->where('id', (int) $row->tenant_id)
|
||||
->value('workspace_id');
|
||||
|
||||
DB::table('operation_runs')->insert([
|
||||
'id' => (int) $row->id,
|
||||
'workspace_id' => (int) $workspaceId,
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'user_id' => $row->user_id,
|
||||
'initiator_name' => $row->initiator_name,
|
||||
'type' => $row->type,
|
||||
'status' => $row->status,
|
||||
'outcome' => $row->outcome,
|
||||
'run_identity_hash' => $row->run_identity_hash,
|
||||
'summary_counts' => $row->summary_counts,
|
||||
'failure_summary' => $row->failure_summary,
|
||||
'context' => $row->context,
|
||||
'started_at' => $row->started_at,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('operation_runs_old');
|
||||
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique_tenant ON operation_runs (tenant_id, run_identity_hash) WHERE tenant_id IS NOT NULL AND status IN ('queued', 'running')");
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique_workspace ON operation_runs (workspace_id, run_identity_hash) WHERE tenant_id IS NULL AND status IN ('queued', 'running')");
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('operation_runs', 'workspace_id')) {
|
||||
Schema::table('operation_runs', function (Blueprint $table) use ($driver): void {
|
||||
$column = $table->foreignId('workspace_id')->nullable();
|
||||
|
||||
if ($driver !== 'sqlite') {
|
||||
$column->after('id')->constrained()->cascadeOnDelete();
|
||||
}
|
||||
|
||||
$table->index(['workspace_id', 'type', 'created_at']);
|
||||
$table->index(['workspace_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
$this->backfillWorkspaceId($driver);
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE operation_runs ALTER COLUMN tenant_id DROP NOT NULL');
|
||||
DB::statement('ALTER TABLE operation_runs ALTER COLUMN workspace_id SET NOT NULL');
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE operation_runs MODIFY tenant_id BIGINT UNSIGNED NULL');
|
||||
DB::statement('ALTER TABLE operation_runs MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
|
||||
}
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS operation_runs_active_unique');
|
||||
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique_tenant ON operation_runs (tenant_id, run_identity_hash) WHERE tenant_id IS NOT NULL AND status IN ('queued', 'running')");
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique_workspace ON operation_runs (workspace_id, run_identity_hash) WHERE tenant_id IS NULL AND status IN ('queued', 'running')");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('operation_runs', 'operation_runs_with_workspace');
|
||||
|
||||
foreach ([
|
||||
'operation_runs_active_unique_tenant',
|
||||
'operation_runs_active_unique_workspace',
|
||||
'operation_runs_workspace_id_type_created_at_index',
|
||||
'operation_runs_workspace_id_created_at_index',
|
||||
'operation_runs_tenant_id_type_created_at_index',
|
||||
'operation_runs_tenant_id_created_at_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('operation_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('initiator_name');
|
||||
$table->string('type');
|
||||
$table->string('status');
|
||||
$table->string('outcome')->default('pending');
|
||||
$table->string('run_identity_hash');
|
||||
$table->json('summary_counts')->default('{}');
|
||||
$table->json('failure_summary')->default('[]');
|
||||
$table->json('context')->default('{}');
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'type', 'created_at']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
});
|
||||
|
||||
DB::table('operation_runs_with_workspace')
|
||||
->whereNotNull('tenant_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('operation_runs')->insert([
|
||||
'id' => (int) $row->id,
|
||||
'tenant_id' => (int) $row->tenant_id,
|
||||
'user_id' => $row->user_id,
|
||||
'initiator_name' => $row->initiator_name,
|
||||
'type' => $row->type,
|
||||
'status' => $row->status,
|
||||
'outcome' => $row->outcome,
|
||||
'run_identity_hash' => $row->run_identity_hash,
|
||||
'summary_counts' => $row->summary_counts,
|
||||
'failure_summary' => $row->failure_summary,
|
||||
'context' => $row->context,
|
||||
'started_at' => $row->started_at,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('operation_runs_with_workspace');
|
||||
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique ON operation_runs (tenant_id, run_identity_hash) WHERE status IN ('queued', 'running')");
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS operation_runs_active_unique_tenant');
|
||||
DB::statement('DROP INDEX IF EXISTS operation_runs_active_unique_workspace');
|
||||
DB::statement('DROP INDEX IF EXISTS operation_runs_active_unique');
|
||||
|
||||
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique ON operation_runs (tenant_id, run_identity_hash) WHERE status IN ('queued', 'running')");
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE operation_runs ALTER COLUMN tenant_id SET NOT NULL');
|
||||
DB::statement('ALTER TABLE operation_runs ALTER COLUMN workspace_id DROP NOT NULL');
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE operation_runs MODIFY tenant_id BIGINT UNSIGNED NOT NULL');
|
||||
DB::statement('ALTER TABLE operation_runs MODIFY workspace_id BIGINT UNSIGNED NULL');
|
||||
}
|
||||
|
||||
Schema::table('operation_runs', function (Blueprint $table): void {
|
||||
$table->dropIndex(['workspace_id', 'type', 'created_at']);
|
||||
$table->dropIndex(['workspace_id', 'created_at']);
|
||||
$table->dropConstrainedForeignId('workspace_id');
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillWorkspaceId(string $driver): void
|
||||
{
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE operation_runs
|
||||
SET workspace_id = tenants.workspace_id
|
||||
FROM tenants
|
||||
WHERE operation_runs.workspace_id IS NULL
|
||||
AND operation_runs.tenant_id = tenants.id
|
||||
SQL);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE operation_runs
|
||||
JOIN tenants ON tenants.id = operation_runs.tenant_id
|
||||
SET operation_runs.workspace_id = tenants.workspace_id
|
||||
WHERE operation_runs.workspace_id IS NULL
|
||||
SQL);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('operation_runs')
|
||||
->whereNull('workspace_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$workspaceId = DB::table('tenants')
|
||||
->where('id', (int) $row->tenant_id)
|
||||
->value('workspace_id');
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
DB::table('operation_runs')
|
||||
->where('id', (int) $row->id)
|
||||
->update(['workspace_id' => (int) $workspaceId]);
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
};
|
||||
@ -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"/>
|
||||
|
||||
@ -0,0 +1,490 @@
|
||||
@php
|
||||
$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 = 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">
|
||||
@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’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
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@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 }}
|
||||
</x-filament::badge>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
@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) !== '' ? 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>
|
||||
</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">
|
||||
@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>
|
||||
@ -0,0 +1,596 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$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;
|
||||
|
||||
$outcome = $run['outcome'] ?? null;
|
||||
$outcome = is_string($outcome) ? $outcome : null;
|
||||
|
||||
$targetScope = $run['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = $run['failures'] ?? [];
|
||||
$failures = is_array($failures) ? $failures : [];
|
||||
|
||||
$completedAt = $run['completed_at'] ?? null;
|
||||
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
|
||||
|
||||
$completedAtLabel = null;
|
||||
|
||||
if ($completedAt !== null) {
|
||||
try {
|
||||
$completedAtLabel = \Carbon\CarbonImmutable::parse($completedAt)->format('Y-m-d H:i');
|
||||
} catch (\Throwable) {
|
||||
$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">
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Verification report"
|
||||
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification run.'"
|
||||
>
|
||||
@if ($run === null)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No verification run has been started yet.
|
||||
</div>
|
||||
@elseif ($status !== 'completed')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
||||
</div>
|
||||
@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>
|
||||
|
||||
<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">
|
||||
@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 : [];
|
||||
$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="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target scope
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@php
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
||||
@endphp
|
||||
|
||||
@if ($entraTenantName !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($entraTenantId !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
||||
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@ -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>
|
||||
@ -13,25 +13,10 @@
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
@if ($this->canRegisterTenant())
|
||||
Register a tenant for this workspace, or switch workspaces.
|
||||
@else
|
||||
Switch workspaces, or contact an administrator.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
@if ($this->canRegisterTenant())
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
tag="a"
|
||||
href="{{ route('filament.admin.tenant.registration') }}"
|
||||
>
|
||||
Register tenant
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$context = is_array($this->run->context ?? null) ? $this->run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
|
||||
$failures = is_array($this->run->failure_summary ?? null) ? $this->run->failure_summary : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Summary">
|
||||
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700 dark:text-gray-200 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (int) $this->run->getKey() }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Workspace:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) ($this->run->workspace?->name ?? '—') }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Operation:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->type }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Initiator:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->initiator_name }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->status }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Outcome:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->outcome }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Started:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->started_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->completed_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Target scope" :collapsed="false">
|
||||
@php
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
||||
@endphp
|
||||
|
||||
@if ($entraTenantId === null && $entraTenantName === null)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No target scope details were recorded for this run.
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($entraTenantName !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Report">
|
||||
@if ((string) $this->run->status !== 'completed')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
||||
</div>
|
||||
@elseif ((string) $this->run->outcome === 'succeeded')
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No failures were reported.
|
||||
</div>
|
||||
@elseif ($failures === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable. The run completed, but no failure details were recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings
|
||||
</div>
|
||||
|
||||
<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;
|
||||
@endphp
|
||||
|
||||
@if ($reasonCode !== null || $message !== null)
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
@if ($reasonCode !== null)
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $reasonCode }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($message !== null)
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</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>
|
||||
@ -0,0 +1,170 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
Managed tenant onboarding
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
This wizard will guide you through identifying a managed tenant and verifying access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($this->managedTenant)
|
||||
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
|
||||
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$verificationSucceeded = $this->verificationSucceeded();
|
||||
$hasTenant = (bool) $this->managedTenant;
|
||||
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 — Identify managed tenant</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $hasTenant ? 'Done' : 'Pending' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
wire:click="mountAction('identifyManagedTenant')"
|
||||
>
|
||||
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 — Provider connection</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($hasTenant)
|
||||
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
|
||||
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
wire:click="mountAction('createProviderConnection')"
|
||||
>
|
||||
Create connection
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
wire:click="mountAction('selectProviderConnection')"
|
||||
>
|
||||
Select connection
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 — Verify access</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
:disabled="! $hasConnection"
|
||||
wire:click="mountAction('startVerification')"
|
||||
>
|
||||
Run verification
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 — Bootstrap (optional)</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
:disabled="! $verificationSucceeded"
|
||||
wire:click="mountAction('startBootstrap')"
|
||||
>
|
||||
Start bootstrap
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 — Complete onboarding</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="success"
|
||||
:disabled="! $verificationSucceeded"
|
||||
wire:click="mountAction('completeOnboarding')"
|
||||
>
|
||||
Complete onboarding
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
@ -17,16 +17,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
@if ($this->canRegisterTenant())
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
tag="a"
|
||||
href="{{ route('filament.admin.tenant.registration') }}"
|
||||
href="{{ route('admin.onboarding') }}"
|
||||
>
|
||||
Add managed tenant
|
||||
Start onboarding
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
@ -29,7 +28,7 @@
|
||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||
->name('admin.consent.start');
|
||||
// Panel root override: keep the app's workspace-first flow.
|
||||
// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant
|
||||
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
|
||||
// when no default tenant can be resolved.
|
||||
Route::middleware([
|
||||
'web',
|
||||
@ -67,7 +66,7 @@
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
return redirect()->to('/admin/onboarding');
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
@ -81,23 +80,6 @@
|
||||
return redirect()->to('/admin/choose-tenant');
|
||||
})
|
||||
->name('admin.home');
|
||||
// Fallback route: Filament's layout generates this URL when tenancy registration is enabled.
|
||||
// In this app, package route registration may not always define it early enough, which breaks
|
||||
// rendering on tenant-scoped routes.
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
])
|
||||
->prefix('/admin')
|
||||
->name('filament.admin.')
|
||||
->get('/register-tenant', RegisterTenant::class)
|
||||
->name('tenant.registration');
|
||||
|
||||
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
|
||||
->name('admin.rbac.start');
|
||||
@ -112,42 +94,6 @@
|
||||
->middleware('throttle:entra-callback')
|
||||
->name('auth.entra.callback');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/managed-tenants', function (Request $request) {
|
||||
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return redirect('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants');
|
||||
})
|
||||
->name('admin.legacy.managed-tenants.index');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/managed-tenants/onboarding', function (Request $request) {
|
||||
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return redirect('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
|
||||
})
|
||||
->name('admin.legacy.managed-tenants.onboarding');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/new', function (Request $request) {
|
||||
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return redirect('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
|
||||
})
|
||||
->name('admin.legacy.onboarding');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||
->name('admin.switch-workspace');
|
||||
@ -173,11 +119,31 @@
|
||||
->name('admin.workspace.home');
|
||||
|
||||
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
|
||||
|
||||
Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant'))
|
||||
->name('admin.workspace.managed-tenants.onboarding');
|
||||
});
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
])
|
||||
->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,
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->name('admin.operations.view');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
|
||||
@ -29,6 +29,10 @@ ## Security hardening (owners / audit / recovery)
|
||||
- [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata.
|
||||
- [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited).
|
||||
|
||||
## Follow-up bugfix
|
||||
- [x] T300 Fix Workspaces → Memberships UI enforcement to use workspace capabilities (not tenant capabilities).
|
||||
- [x] T310 Add regression tests for WorkspaceMemberships relation manager action enable/disable.
|
||||
|
||||
## Validation
|
||||
- [x] T900 Run Pint on dirty files.
|
||||
- [x] T910 Run targeted Pest tests.
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Managed Tenant Onboarding Wizard V1 (Enterprise)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-04
|
||||
**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
|
||||
|
||||
- Clarifications resolved: global Entra Tenant ID uniqueness (bound to one workspace), owner-only activation override with reason + audit, workspace-owned provider connections bound to a tenant by default (reuse off by default).
|
||||
- Spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,62 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot — Managed Tenant Onboarding (073)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Onboarding wizard + tenantless operation run viewer routes.
|
||||
|
||||
These are UI endpoints (Filament/Livewire), documented here for contract clarity.
|
||||
servers:
|
||||
- url: https://example.invalid
|
||||
paths:
|
||||
/admin/onboarding:
|
||||
get:
|
||||
summary: Managed tenant onboarding wizard (canonical entry point)
|
||||
responses:
|
||||
'200':
|
||||
description: Renders onboarding wizard page in the current workspace context.
|
||||
'302':
|
||||
description: Redirects to workspace chooser when no workspace is selected.
|
||||
'403':
|
||||
description: Workspace member missing onboarding capability.
|
||||
'404':
|
||||
description: Workspace not found or user is not a member (deny-as-not-found).
|
||||
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Tenantless operation run viewer
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Renders operation run details.
|
||||
'404':
|
||||
description: Run not found or actor is not a member of the run workspace (deny-as-not-found).
|
||||
|
||||
/admin/register-tenant:
|
||||
get:
|
||||
summary: Legacy tenant registration entry point
|
||||
deprecated: true
|
||||
responses:
|
||||
'404':
|
||||
description: Must be removed / behave as not found (FR-001).
|
||||
|
||||
/admin/new:
|
||||
get:
|
||||
summary: Legacy onboarding entry point
|
||||
deprecated: true
|
||||
responses:
|
||||
'404':
|
||||
description: Must not exist and must behave as not found (FR-004).
|
||||
|
||||
/admin/managed-tenants/onboarding:
|
||||
get:
|
||||
summary: Legacy onboarding entry point
|
||||
deprecated: true
|
||||
responses:
|
||||
'404':
|
||||
description: Must not exist and must behave as not found (FR-004).
|
||||
@ -0,0 +1,80 @@
|
||||
# Onboarding Wizard — Action Contracts (073)
|
||||
|
||||
These are conceptual contracts for the wizard’s server-side actions (Filament / Livewire).
|
||||
They define inputs/outputs and authorization semantics.
|
||||
|
||||
## Identify tenant
|
||||
|
||||
- **Purpose:** Upsert or resume onboarding and ensure the managed tenant identity (Entra Tenant ID) is globally unique and bound to a single workspace.
|
||||
- **Inputs:**
|
||||
- `entra_tenant_id` (string)
|
||||
- `environment` (string)
|
||||
- `name` (string)
|
||||
- `primary_domain` (string|null)
|
||||
- `notes` (string|null)
|
||||
- **Outputs:**
|
||||
- `managed_tenant_id` (internal DB id)
|
||||
- `onboarding_session_id`
|
||||
- `current_step`
|
||||
- **Errors:**
|
||||
- 404: workspace not found, actor not a workspace member, or Entra Tenant ID exists in a different workspace (deny-as-not-found)
|
||||
- 403: actor is a workspace member but lacks onboarding capability
|
||||
|
||||
## Select or create Provider Connection
|
||||
|
||||
- **Purpose:** Select an existing provider connection in the workspace or create a new one (secrets captured safely).
|
||||
- **Inputs:**
|
||||
- `provider_connection_id` (int|null)
|
||||
- (optional) connection creation fields (non-secret identifiers only)
|
||||
- **Outputs:**
|
||||
- `provider_connection_id`
|
||||
- `is_default` (bool)
|
||||
- **Errors:**
|
||||
- 404: connection/tenant not in workspace scope
|
||||
- 403: member missing capability
|
||||
|
||||
## Start verification
|
||||
|
||||
- **Purpose:** Start provider connection verification asynchronously.
|
||||
- **Mechanism:** Create/reuse `OperationRun` of type `provider.connection.check`, enqueue `ProviderConnectionHealthCheckJob`.
|
||||
- **Inputs:** none (uses selected connection)
|
||||
- **Outputs:**
|
||||
- `operation_run_id`
|
||||
- `status` (queued/running/succeeded/failed)
|
||||
- **Errors:**
|
||||
- 404: tenant/connection not in workspace scope
|
||||
- 403: member missing capability
|
||||
|
||||
**View run link contract:**
|
||||
- The UI must expose a tenantless “View run” URL: `/admin/operations/{run}`.
|
||||
- Access is granted only if the actor is a member of the run’s workspace; otherwise 404 (deny-as-not-found).
|
||||
|
||||
## Optional bootstrap actions
|
||||
|
||||
- **Purpose:** Start selected post-verify operations as separate runs.
|
||||
- **Inputs:** list of operation types (must exist in registry)
|
||||
- **Outputs:** list of `operation_run_id`
|
||||
- **Errors:**
|
||||
- 403/404 semantics as above
|
||||
|
||||
## Activate (Complete)
|
||||
|
||||
- **Purpose:** Activate the managed tenant, making it available in the tenant switcher.
|
||||
- **Preconditions:** Provider connection exists; verification is not Blocked unless overridden by an owner.
|
||||
- **Inputs:**
|
||||
- `override_blocked` (bool, optional)
|
||||
- `override_reason` (string, required if override)
|
||||
- **Outputs:**
|
||||
- `managed_tenant_id`
|
||||
- `status` (active)
|
||||
- **Errors:**
|
||||
- 404: managed tenant not in workspace scope / actor not a member
|
||||
- 403: actor is a member but not an owner (owner-only activation); or missing capability
|
||||
|
||||
**Audit requirement:**
|
||||
- Any override must record an audit event including the human-entered reason.
|
||||
|
||||
## Security & data minimization
|
||||
|
||||
- Stored secrets must never be returned.
|
||||
- Failures are stored as stable reason codes + sanitized messages.
|
||||
120
specs/073-unified-managed-tenant-onboarding-wizard/data-model.md
Normal file
120
specs/073-unified-managed-tenant-onboarding-wizard/data-model.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Data Model — Managed Tenant Onboarding Wizard V1 (Enterprise) (073)
|
||||
|
||||
## Entities
|
||||
|
||||
### Workspace
|
||||
|
||||
Existing entity: `App\Models\Workspace`
|
||||
|
||||
- Onboarding is always initiated within a selected workspace context.
|
||||
- Workspace membership is the primary isolation boundary for wizard + tenantless operations viewing.
|
||||
|
||||
### Tenant (Managed Tenant)
|
||||
|
||||
Existing model: `App\Models\Tenant`
|
||||
|
||||
**Key fields (existing or to extend):**
|
||||
|
||||
- `id` (PK)
|
||||
- `workspace_id` (FK → workspaces)
|
||||
- `tenant_id` (string; Entra Tenant ID) — spec’s `entra_tenant_id` (globally unique)
|
||||
- `external_id` (string; Filament tenant route key; currently used in `/admin/t/{tenant}`)
|
||||
- `name` (string)
|
||||
- `primary_domain` (string|null)
|
||||
- `notes` (text|null)
|
||||
- `environment` (string)
|
||||
- `status` (string) — v1 lifecycle:
|
||||
- `draft`
|
||||
- `onboarding`
|
||||
- `active`
|
||||
- `archived`
|
||||
|
||||
**Indexes / constraints (design intent):**
|
||||
|
||||
- Unique: `tenant_id` (global uniqueness; binds the tenant to exactly one workspace)
|
||||
- `external_id` must remain globally unique for Filament tenancy routing
|
||||
|
||||
**State transitions:**
|
||||
|
||||
- `draft` → `onboarding` after identification is recorded
|
||||
- `onboarding` → `active` on owner activation
|
||||
- `active` → `archived` via archive/deactivate workflow
|
||||
|
||||
### Provider Connection
|
||||
|
||||
Existing model today: `App\Models\ProviderConnection` (currently tenant-owned)
|
||||
|
||||
**Spec-aligned ownership model (design intent):**
|
||||
|
||||
- Provider connections are workspace-owned.
|
||||
- Default binding: provider connection bound to exactly one managed tenant.
|
||||
- Reuse across managed tenants is disabled by default and policy-gated.
|
||||
|
||||
**Proposed key fields (target):**
|
||||
|
||||
- `id` (PK)
|
||||
- `workspace_id` (FK → workspaces)
|
||||
- `managed_tenant_id` (FK → tenants.id; required in v1 default binding)
|
||||
- `provider` (string)
|
||||
- `entra_tenant_id` (string)
|
||||
- `is_default` (bool)
|
||||
- `metadata` (json)
|
||||
|
||||
### Tenant Onboarding Session (new)
|
||||
|
||||
New model/table to persist resumable onboarding state for a workspace + Entra Tenant ID.
|
||||
Must never persist secrets and must render DB-only.
|
||||
|
||||
**Proposed fields:**
|
||||
|
||||
- `id` (PK)
|
||||
- `workspace_id` (FK)
|
||||
- `managed_tenant_id` (FK → tenants.id; nullable until tenant is created)
|
||||
- `entra_tenant_id` (string; denormalized identity key; globally unique across the system but still stored for idempotency)
|
||||
- `current_step` (string; `identify`, `connection`, `verify`, `bootstrap`, `complete`)
|
||||
- `state` (jsonb) — safe fields only (no secrets)
|
||||
- `tenant_name`
|
||||
- `environment`
|
||||
- `primary_domain`
|
||||
- `notes`
|
||||
- `selected_provider_connection_id`
|
||||
- `verification_run_id` (OperationRun id)
|
||||
- `bootstrap_run_ids` (array)
|
||||
- `started_by_user_id` (FK users)
|
||||
- `updated_by_user_id` (FK users)
|
||||
- `completed_at` (timestamp|null)
|
||||
- timestamps
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Unique: `entra_tenant_id` (global uniqueness) OR (if sessions are separate from tenants) unique `(workspace_id, entra_tenant_id)` with an additional global “tenant exists elsewhere” guard to enforce deny-as-not-found.
|
||||
|
||||
### Operation Run
|
||||
|
||||
Existing model: `App\Models\OperationRun`
|
||||
|
||||
**Spec-aligned visibility model (design intent):**
|
||||
|
||||
- Runs are viewable tenantlessly at `/admin/operations/{run}`.
|
||||
- Access is granted only to members of the run’s workspace; non-member → deny-as-not-found (404).
|
||||
|
||||
**Proposed schema changes:**
|
||||
|
||||
- Add `workspace_id` (FK → workspaces), required.
|
||||
- Allow `tenant_id` to be nullable for pre-activation runs.
|
||||
- Maintain DB-level active-run idempotency:
|
||||
- `UNIQUE (tenant_id, run_identity_hash) WHERE tenant_id IS NOT NULL AND status IN ('queued', 'running')`
|
||||
- `UNIQUE (workspace_id, run_identity_hash) WHERE tenant_id IS NULL AND status IN ('queued', 'running')`
|
||||
|
||||
## Validation rules (high level)
|
||||
|
||||
- `entra_tenant_id`: required, non-empty, validate GUID format.
|
||||
- Tenant identification requires: `name`, `environment`, `entra_tenant_id`.
|
||||
- Provider connection selected/created must be in the same workspace.
|
||||
- Onboarding session `state` must be strictly whitelisted fields (no secrets).
|
||||
|
||||
## Authorization boundaries
|
||||
|
||||
- Workspace membership boundary: non-member → 404 (deny-as-not-found) for onboarding and tenantless operations run viewing.
|
||||
- Capability boundary (within membership): action attempts without capability → 403.
|
||||
- Owner-only boundary: activation and blocked override require workspace owner; override requires reason + audit.
|
||||
118
specs/073-unified-managed-tenant-onboarding-wizard/plan.md
Normal file
118
specs/073-unified-managed-tenant-onboarding-wizard/plan.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Implementation Plan: Managed Tenant Onboarding Wizard V1 (Enterprise)
|
||||
|
||||
**Branch**: `073-unified-managed-tenant-onboarding-wizard` | **Date**: 2026-02-04 | **Spec**: specs/073-unified-managed-tenant-onboarding-wizard/spec.md
|
||||
**Input**: Feature specification from specs/073-unified-managed-tenant-onboarding-wizard/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver a single onboarding entry point at `/admin/onboarding` that is workspace-first and tenantless until activation. Verification and optional bootstrap actions run asynchronously as `OperationRun`s and are viewable via a tenantless URL `/admin/operations/{run}` with workspace-membership based 404 semantics.
|
||||
|
||||
This requires:
|
||||
- Updating onboarding routing and removing legacy entry points.
|
||||
- Making the operations run viewer safe and usable without a selected workspace and without tenant routing.
|
||||
- Ensuring RBAC-UX semantics (non-member → 404, member missing capability → 403) while keeping UI discoverability (disabled+tooltip).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: macOS dev + Sail containers; deployed in containers (Dokploy)
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Wizard + Monitoring pages render DB-only (no external calls); queued work for Graph
|
||||
**Constraints**:
|
||||
- Canonical entry `/admin/onboarding` only
|
||||
- Tenantless operations viewer `/admin/operations/{run}` must not require selected workspace and must not auto-switch workspaces
|
||||
- Secrets never rendered after capture; no secrets in operation run failures/audits
|
||||
**Scale/Scope**: Multi-workspace admin app; onboarding must be safe, resumable, and regression-tested
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: Not directly impacted.
|
||||
- Read/write separation: activation + overrides are write paths → audit + tests.
|
||||
- Graph contract path: verification/bootstrap Graph calls only via `GraphClientInterface` and `config/graph_contracts.php` (including connectivity probes like `organization` and service-principal permission lookups).
|
||||
- Deterministic capabilities: wizard uses canonical capability registry; no role-string checks.
|
||||
- RBAC-UX: enforce 404/403 semantics; server-side authorizes all actions; UI disabled state is informational only.
|
||||
- Authorization planes: tenant plane (Entra users) only; no platform plane (`/system`) routes or cross-plane behavior.
|
||||
- Run observability: verification/bootstrap runs use `OperationRun`; render remains DB-only.
|
||||
- Data minimization: never persist secrets in session/state/report/audit.
|
||||
- Badge semantics: status chips use centralized badge mapping.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/073-unified-managed-tenant-onboarding-wizard/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ ├── http.openapi.yaml
|
||||
│ └── onboarding-actions.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/Pages/
|
||||
├── Filament/Resources/
|
||||
├── Http/Middleware/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
database/migrations/
|
||||
tests/Feature/
|
||||
```
|
||||
|
||||
**Structure Decision**: Implement onboarding as a Filament Page under `app/Filament/Pages` and keep operations viewing on `OperationRunResource`, but change authorization/middleware to support tenantless viewing.
|
||||
|
||||
## Phase 0 — Research
|
||||
|
||||
See: specs/073-unified-managed-tenant-onboarding-wizard/research.md
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
See:
|
||||
- specs/073-unified-managed-tenant-onboarding-wizard/data-model.md
|
||||
- specs/073-unified-managed-tenant-onboarding-wizard/contracts/http.openapi.yaml
|
||||
- specs/073-unified-managed-tenant-onboarding-wizard/contracts/onboarding-actions.md
|
||||
- specs/073-unified-managed-tenant-onboarding-wizard/quickstart.md
|
||||
|
||||
## Phase 2 — Planning (implementation outline)
|
||||
|
||||
1) Routing
|
||||
- Add `/admin/onboarding` (canonical, sole entry point).
|
||||
- Remove legacy entry points (404; no redirects): `/admin/new`, `/admin/managed-tenants/onboarding`, and any tenant-scoped onboarding/create entry points.
|
||||
|
||||
2) Tenantless operations run viewer
|
||||
- Exempt `/admin/operations/{run}` from forced workspace selection (`EnsureWorkspaceSelected`) and from tenant auto-selection side effects when needed.
|
||||
- Authorize `OperationRun` viewing by workspace membership derived from the run (non-member → 404).
|
||||
|
||||
3) OperationRun model + schema alignment
|
||||
- Add `operation_runs.workspace_id` and support tenantless runs (`tenant_id` nullable) if onboarding verification/bootstraps start before activation.
|
||||
- Preserve DB-level active-run dedupe with partial unique indexes for both tenant-bound and tenantless runs.
|
||||
|
||||
4) Wizard authorization model
|
||||
- Gate wizard actions per canonical capabilities; keep controls visible-but-disabled with tooltip; server-side returns 403 for execution.
|
||||
- Activation is owner-only; blocked override requires reason + audit.
|
||||
|
||||
5) Tests
|
||||
- Add/extend Pest feature tests for:
|
||||
- canonical `/admin/onboarding` routing
|
||||
- legacy entry points 404
|
||||
- `/admin/operations/{run}` membership→404 behavior without selected workspace
|
||||
- 403 for member action attempts without capability
|
||||
- owner-only activation + override audit reason
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations expected; changes are localized and gated by tests.
|
||||
@ -0,0 +1,35 @@
|
||||
# Quickstart — Unified Managed Tenant Onboarding Wizard (073)
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install` and `vendor/bin/sail npm install`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
- Run frontend build/dev:
|
||||
- `vendor/bin/sail npm run dev` (watch)
|
||||
- or `vendor/bin/sail npm run build`
|
||||
|
||||
## Using the wizard (expected flow)
|
||||
|
||||
1) Sign in to `/admin`.
|
||||
2) Open `/admin/onboarding`.
|
||||
3) If no workspace is selected, you are redirected to `/admin/choose-workspace`.
|
||||
4) Complete Identify → Connection → Verify (queued) → optional Bootstrap → Activate.
|
||||
|
||||
Notes:
|
||||
|
||||
- The onboarding UI must render DB-only; Graph calls occur only in queued work.
|
||||
- Verification/bootstrap are tracked as `OperationRun`s.
|
||||
- The “View run” link must open `/admin/operations/{run}` (tenantless). This page must be accessible without a selected workspace, but only to members of the run’s workspace.
|
||||
|
||||
## Tests
|
||||
|
||||
Run targeted tests (expected file name when implemented):
|
||||
|
||||
- `vendor/bin/sail artisan test --compact --filter=Onboarding`
|
||||
|
||||
## Deploy / Ops
|
||||
|
||||
If Filament assets are used/registered, deployment must include:
|
||||
|
||||
- `php artisan filament:assets`
|
||||
@ -0,0 +1,67 @@
|
||||
# Research — Managed Tenant Onboarding Wizard V1 (Enterprise) (073)
|
||||
|
||||
This document resolves planning unknowns and records key implementation decisions aligned with the clarified spec.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Managed Tenant model = existing `Tenant`
|
||||
|
||||
- **Decision:** Treat `App\Models\Tenant` as the “Managed Tenant” record.
|
||||
- **Rationale:** Filament tenancy, membership model, and tenant-scoped flows already depend on `Tenant`; duplicating a second tenant-like table would multiply authorization and routing complexity.
|
||||
- **Alternatives considered:** Introduce a new `ManagedTenant` model/table.
|
||||
- **Why rejected:** Duplicates tenancy and membership boundaries; increases cross-plane leak risk.
|
||||
|
||||
### 2) Entra Tenant ID uniqueness = global, bound to one workspace
|
||||
|
||||
- **Decision:** Enforce global uniqueness for `tenants.tenant_id` (Entra Tenant ID) and bind it to exactly one workspace (the workspace_id on the tenant).
|
||||
- **Rationale:** Matches FR-011 and the clarification decision (“global uniqueness bound to one workspace”).
|
||||
- **Alternatives considered:** Allow the same Entra Tenant ID in multiple workspaces.
|
||||
- **Why rejected:** Violates the clarified requirement and complicates deny-as-not-found behavior.
|
||||
|
||||
### 3) Canonical onboarding entry point = `/admin/onboarding` (only)
|
||||
|
||||
- **Decision:** Provide `/admin/onboarding` as the sole onboarding entry point.
|
||||
- **Rationale:** Keeps a single user-facing URL for enterprise workflows; avoids fragmented legacy entry points.
|
||||
- **Alternatives considered:** Workspace-scoped onboarding route (`/admin/w/{workspace}/...`).
|
||||
- **Why rejected:** Conflicts with clarified spec (canonical `/admin/onboarding` only).
|
||||
|
||||
### 4) Tenantless operations viewer = existing `OperationRunResource` route `/admin/operations/{run}`
|
||||
|
||||
- **Decision:** Keep the route shape `/admin/operations/{run}` (already provided by `OperationRunResource` slug `operations`) and make it compliant by changing authorization + middleware behavior.
|
||||
- **Rationale:** Minimizes routing surface area and leverages existing Monitoring → Operations UI.
|
||||
- **Alternatives considered:** Create a separate “run viewer” page outside the resource.
|
||||
- **Why rejected:** Duplicates infolist rendering and complicates observability conventions.
|
||||
|
||||
### 5) `/admin/operations/{run}` must not require selected workspace or auto-switch
|
||||
|
||||
- **Decision:** Exempt `/admin/operations/{run}` from forced workspace selection and from any “auto selection” side effects that would prevent tenantless viewing.
|
||||
- **Rationale:** Spec requires (a) no workspace in the URL, (b) no pre-selected workspace required, (c) no auto-switching.
|
||||
- **Alternatives considered:** Keep current `EnsureWorkspaceSelected` behavior (redirect to choose workspace).
|
||||
- **Why rejected:** Violates FR-017a and can leak resource existence via redirects.
|
||||
|
||||
### 6) OperationRun authorization = workspace membership (non-member → 404)
|
||||
|
||||
- **Decision:** Authorize viewing a run by checking membership in the run’s workspace; non-member gets deny-as-not-found (404).
|
||||
- **Rationale:** FR-017a defines access semantics; runs must be viewable tenantlessly before activation.
|
||||
- **Alternatives considered:** Authorize by `Tenant::current()` + matching `run.tenant_id`.
|
||||
- **Why rejected:** Requires tenant routing/selection and breaks tenantless viewing.
|
||||
|
||||
### 7) OperationRun schema = add `workspace_id`, allow tenantless runs, preserve idempotency
|
||||
|
||||
- **Decision:** Add `operation_runs.workspace_id` (FK) and allow `tenant_id` to be nullable for pre-activation operations. Preserve DB-level dedupe using two partial unique indexes:
|
||||
- Tenant-bound runs: `UNIQUE (tenant_id, run_identity_hash) WHERE tenant_id IS NOT NULL AND status IN ('queued', 'running')`
|
||||
- Tenantless runs: `UNIQUE (workspace_id, run_identity_hash) WHERE tenant_id IS NULL AND status IN ('queued', 'running')`
|
||||
- **Rationale:** Enables tenantless operations while preserving race-safe idempotency guarantees.
|
||||
- **Alternatives considered:** Keep `tenant_id` required and always derive workspace via join.
|
||||
- **Why rejected:** Blocks tenantless flows and makes authorization join-dependent.
|
||||
|
||||
### 8) Provider connection ownership = workspace-owned, default 1:1 binding
|
||||
|
||||
- **Decision:** Align Provider Connections to be workspace-owned and (by default) bound to exactly one managed tenant; reuse is disabled by default and policy-gated.
|
||||
- **Rationale:** Matches FR-022/022a/022b and reduces blast radius of credential reuse.
|
||||
- **Alternatives considered:** Keep provider connections tenant-owned.
|
||||
- **Why rejected:** Conflicts with clarified spec ownership model.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None for planning; implementation will need to reconcile existing DB schema and policies with the decisions above.
|
||||
189
specs/073-unified-managed-tenant-onboarding-wizard/spec.md
Normal file
189
specs/073-unified-managed-tenant-onboarding-wizard/spec.md
Normal file
@ -0,0 +1,189 @@
|
||||
# Feature Specification: Managed Tenant Onboarding Wizard V1 (Enterprise)
|
||||
|
||||
**Feature Branch**: `073-unified-managed-tenant-onboarding-wizard`
|
||||
**Created**: 2026-02-04
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 073 — Managed Tenant Onboarding Wizard V1 (Enterprise): single workspace-first wizard as source of truth, tenantless until activation; legacy entry points removed; strict 404/403 semantics; verification checklist with tenantless run page; optional bootstrap; enterprise-grade UX and regression tests."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-04
|
||||
|
||||
- Q: Capability granularity for the wizard? → A: Per-step/per-action capabilities (least-privilege). Activation is owner-only; bootstrap actions are separately gated.
|
||||
- Q: For members without capability, should actions be hidden or disabled? → A: Visible but disabled, with tooltip/explanation; server-side remains authoritative.
|
||||
- Q: What is the tenantless “View run” URL pattern? → A: `/admin/operations/{run}` (no workspace in path), access-controlled by run.workspace membership (non-member → 404), no auto workspace switching.
|
||||
- Q: What is the canonical onboarding entry point URL? → A: `/admin/onboarding` (sole entry point in V1; no aliases).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Start onboarding from a single entry point (Priority: P1)
|
||||
|
||||
As a workspace member, I can open a single onboarding entry point and start (or resume) onboarding for a Managed Tenant in the currently selected workspace, so that tenant onboarding is consistent, workspace-first, and safe.
|
||||
|
||||
**Why this priority**: This is the foundation for all onboarding work and replaces fragmented legacy flows.
|
||||
|
||||
**Independent Test**: Can be fully tested by visiting `/admin/onboarding` with and without a selected workspace, completing Step 1, and verifying that a single tenant is created or resumed without duplicates.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** no workspace is selected, **When** a user visits `/admin/onboarding`, **Then** they are redirected to choose a workspace.
|
||||
2. **Given** a workspace is selected and has no active tenants, **When** a user visits the onboarding entry point, **Then** the onboarding wizard opens directly.
|
||||
3. **Given** a workspace is selected and has at least one active tenant, **When** a user visits the onboarding entry point, **Then** the onboarding wizard is still reachable via an “Add managed tenant” call-to-action.
|
||||
4. **Given** the user identifies a tenant using an Entra Tenant ID that already exists in the same workspace, **When** they submit Step 1 again, **Then** the wizard stays on Step 1 and shows a notification that the tenant already exists with a link to open it.
|
||||
5. **Given** the user provides an Entra Tenant ID that exists in a different workspace, **When** they submit Step 1, **Then** the system responds with deny-as-not-found behavior and the UI shows a generic “Not found” notification (no details leaked).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Attach or create a provider connection safely (Priority: P2)
|
||||
|
||||
As a workspace member, I can choose an existing provider connection or create a new one during onboarding, so that the system has a valid technical connection without exposing secret material.
|
||||
|
||||
**Why this priority**: Without a valid connection, verification and activation cannot be completed safely.
|
||||
|
||||
**Independent Test**: Can be tested by selecting “Use existing connection” vs “Create new connection”, ensuring secrets are masked and never displayed again, and verifying that onboarding state stores no secrets.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user chooses “Use existing connection”, **When** they select a connection and proceed, **Then** onboarding records the chosen connection and continues.
|
||||
2. **Given** the user chooses “Create new connection”, **When** they input connection details, **Then** any secret input is masked and is not retrievable from the UI later.
|
||||
3. **Given** the user starts Step 2 but leaves before finishing, **When** they resume onboarding later, **Then** only non-secret inputs are prefilled and secret material is never shown.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Verify access and review results without tenant-scoped context (Priority: P3)
|
||||
|
||||
As a workspace member, I can start a verification run, manually refresh its status, and view a stored checklist report (including a tenantless “View run” page), so that verification works even before the tenant is activated and without using tenant-scoped routes.
|
||||
|
||||
**Why this priority**: Verification is the safety gate that enables activation, and it must work in empty workspaces and pre-activation flows.
|
||||
|
||||
**Independent Test**: Can be tested by starting verification, asserting idempotent dedupe while a run is active, verifying the viewer renders using stored data only, and verifying the “View run” link is tenantless.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** verification has not been started, **When** the user clicks “Start verification”, **Then** a new verification run is started and the UI shows that verification is in progress.
|
||||
2. **Given** a verification run is active, **When** the user clicks “Start verification” again, **Then** the system dedupes the request and does not create a second active run.
|
||||
3. **Given** a verification run is active, **When** the user clicks “Refresh”, **Then** the UI updates status using stored run state.
|
||||
4. **Given** verification completes with any blocking failures, **When** the report is shown, **Then** the step status is “Blocked”.
|
||||
5. **Given** verification completes with warnings but no blocking failures, **When** the report is shown, **Then** the step status is “Needs attention”.
|
||||
6. **Given** verification completes with no warnings and no failures, **When** the report is shown, **Then** the step status is “Ready”.
|
||||
7. **Given** the UI shows a “View run” link, **When** the user clicks it, **Then** it opens a tenantless operations URL (not a tenant-scoped URL).
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Visiting legacy entry points returns “not found” behavior (no redirects).
|
||||
- A non-member of the selected workspace receives deny-as-not-found behavior for the onboarding entry point.
|
||||
- A workspace member without the required capability can see the page, but action controls are disabled and show a tooltip; server-side action attempts are denied with 403.
|
||||
- Activation is owner-only: non-owners can see Step 5 but cannot activate; the UI explains “Owner required”, and server-side attempts are denied.
|
||||
- Bootstrap actions are optional and gated independently per action; non-authorized users cannot start them.
|
||||
- The wizard must not generate or require tenant-scoped links before activation.
|
||||
- Manual refresh should not trigger external network calls; it may only re-read stored status/report.
|
||||
- Verification report content must never contain secrets/tokens, raw headers, or credential material.
|
||||
- Completing onboarding while verification is blocked is prevented unless an explicit override policy applies.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Authorization plane(s) involved (filled for this feature):**
|
||||
- **Tenant plane (Entra users)** only. This feature adds tenantless, workspace-scoped routes under `/admin/*` (`/admin/onboarding`, `/admin/operations/{run}`) that must still enforce tenant-plane membership and capability rules.
|
||||
- **Platform plane (`/system`) is out of scope**. No cross-plane navigation is introduced; deny-as-not-found (404) semantics remain the default for non-members / not entitled.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Single onboarding entry point)**: The system MUST provide a single onboarding entry point at `/admin/onboarding` that is the source of truth for onboarding.
|
||||
- **FR-002 (Workspace required)**: If no workspace is selected, the onboarding entry point MUST redirect the user to a workspace chooser.
|
||||
- **FR-003 (Workspace landing behavior)**: With a selected workspace, the system MUST:
|
||||
- open the wizard directly when the workspace has zero active tenants, and
|
||||
- keep the wizard reachable via an “Add managed tenant” call-to-action when the workspace has one or more active tenants.
|
||||
- **FR-004 (Remove legacy entry points)**: The following legacy entry points MUST NOT exist and MUST return “not found” behavior (no redirects):
|
||||
- `/admin/new`
|
||||
- any legacy tenant-scoped create entry point
|
||||
- `/admin/managed-tenants/onboarding` (legacy)
|
||||
- **FR-005 (Membership boundary)**: A non-member of the selected workspace MUST always receive deny-as-not-found behavior for onboarding and for any workspace-visible operations.
|
||||
- **FR-006 (Capability boundary)**: A workspace member without the required capability MUST be able to view the page, but action controls MUST be disabled with an explanatory tooltip; server-side action attempts MUST be denied with 403.
|
||||
- **FR-006d (Discoverability default)**: In V1, capability-gated controls SHOULD remain visible but disabled with an explanation (rather than being hidden), to support enterprise operator workflows.
|
||||
- **FR-006a (Least-privilege capability model)**: The wizard MUST gate each step and each action by canonical capabilities (no ad-hoc role string checks).
|
||||
- **FR-006b (Wizard capability breakdown)**: The system MUST support, at minimum, distinct capability gates for:
|
||||
- identifying / creating / resuming onboarding for a managed tenant,
|
||||
- viewing/selecting a provider connection,
|
||||
- creating/editing a provider connection,
|
||||
- starting verification,
|
||||
- running each optional bootstrap action (inventory sync, policy sync, backup bootstrap) independently,
|
||||
- activating a tenant.
|
||||
- **FR-006c (Viewer visibility)**: Viewing verification reports and operation-run results MUST be permitted to workspace members (subject to workspace membership), even when they cannot start runs.
|
||||
- **FR-007 (Workspace↔tenant match hard rule)**: For any tenant-scoped route, if the tenant does not belong to the currently selected workspace, the system MUST return deny-as-not-found behavior.
|
||||
- **FR-008 (Tenantless wizard until activation)**: The wizard MUST not require tenant-scoped pages, routes, or links before the final “Complete / Activate” step.
|
||||
- **FR-009 (Identify managed tenant inputs)**: Step 1 MUST capture, at minimum:
|
||||
- tenant name,
|
||||
- environment,
|
||||
- Entra Tenant ID,
|
||||
- optional primary domain,
|
||||
- optional notes.
|
||||
- **FR-010 (Idempotent identification)**: Step 1 MUST be idempotent for the same tenant identifier within the same workspace and MUST resume an active onboarding session when applicable.
|
||||
- **FR-011 (Uniqueness of Entra Tenant ID)**: The system MUST enforce Entra Tenant ID uniqueness globally, and each Entra Tenant ID MUST be bound to exactly one workspace in V1.
|
||||
- **FR-012 (Tenant status model)**: Managed Tenants MUST support a v1 lifecycle including: `draft`, `onboarding`, `active`, `archived`.
|
||||
- **FR-013 (Provider connection choice)**: Step 2 MUST let the user either use an existing connection or create a new connection.
|
||||
- **FR-014 (Secret safety)**: Any secret material entered during connection creation MUST be masked, stored securely, and MUST never be displayed again. Onboarding session state MUST not store secret material.
|
||||
- **FR-015 (Verification run start)**: Step 3 MUST allow starting a verification run and MUST dedupe requests while an active verification run exists.
|
||||
- **FR-016 (Verification viewer behavior)**: Step 3 MUST display a stored checklist report with:
|
||||
- an “in progress” banner while a run is active,
|
||||
- a manual “Refresh” control,
|
||||
- status mapping: blocking failures → Blocked; warnings-only → Needs attention; otherwise → Ready,
|
||||
- “Next steps” as links only (no server-side actions in V1).
|
||||
- **FR-017 (Tenantless operations page)**: The wizard’s “View run” link MUST point to `/admin/operations/{run}` and MUST never use a tenant-scoped operations URL.
|
||||
- **FR-017a (Tenantless access semantics)**: Access to `/admin/operations/{run}` MUST be granted only if the user is a member of the run’s workspace; otherwise the system MUST respond with deny-as-not-found behavior. The page MUST NOT require a pre-selected workspace context and MUST NOT auto-switch workspaces.
|
||||
- **FR-018 (Workspace-visible operations)**: Operation runs started by the wizard MUST be safely viewable in a workspace context without tenant-scoped routing and MUST honor the same deny-as-not-found membership boundary.
|
||||
- **FR-019 (Optional bootstrap step)**: Step 4 MAY offer optional bootstrap actions (e.g., inventory sync, policy sync, baseline creation) with per-action capability gating; each selected action MUST start its own operation run and be viewable tenantlessly.
|
||||
- **FR-020 (Complete / Activate gate)**: The wizard MUST only allow activation when a provider connection exists and verification is not Blocked, except when a workspace owner explicitly overrides the block.
|
||||
- **FR-020a (Override requirements)**: When overriding a blocked verification, the system MUST require a human-entered reason and MUST record an audit event capturing the override decision and reason.
|
||||
- **FR-020b (Owner-only activation)**: Activation MUST be restricted to workspace owners (non-owner members may not activate, even if they can run earlier steps).
|
||||
- **FR-021 (Activation outcome)**: On activation, the tenant MUST become visible in the workspace tenant switcher and the user MUST be redirected either to the tenant home (open now) or back to the workspace managed tenant list.
|
||||
- **FR-022 (Connection ownership model)**: Provider connections MUST be workspace-owned.
|
||||
- **FR-022a (Safe default binding)**: By default in V1, a provider connection MUST be bound to exactly one managed tenant.
|
||||
- **FR-022b (Reuse safety gate)**: Reuse of an existing provider connection for additional managed tenants MUST be disabled by default and MUST only be possible via an explicit opt-in that clearly communicates risk and is policy-gated.
|
||||
- **FR-023 (Auditability)**: The system MUST record audit events for: tenant identification, connection creation/updates, verification start/completion, bootstrap run start/completion, and activation.
|
||||
- **FR-024 (DB-only rendering)**: The wizard and the verification viewer MUST render using stored data only; any external checks MUST run as background work.
|
||||
- **FR-025 (Badge semantics)**: Step-status and verification-result chips MUST use centralized badge semantics (no per-page ad-hoc mappings), and changes MUST be covered by automated tests.
|
||||
- **FR-026 (Graph contract path)**: Any Microsoft Graph call made by verification/bootstrap runs MUST go through the canonical contract registry path (`GraphClientInterface` + `config/graph_contracts.php`). Feature code MUST NOT hardcode ad-hoc endpoints; missing contracts MUST fail safe and be covered by automated tests.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: A portfolio context that a user selects; controls membership and owns one or more managed tenants.
|
||||
- **Managed Tenant**: A record representing a Microsoft tenant managed by the organization; includes identity (Entra Tenant ID), environment, and lifecycle status.
|
||||
- **Onboarding Session**: A resumable record of onboarding progress and safe, non-secret state.
|
||||
- **Provider Connection**: A technical connection configuration used to access tenant data; includes secret material that must never be displayed after capture.
|
||||
- **Operation Run**: A trackable background run started by the wizard (verification and optional bootstrap actions) with a stored report suitable for safe, tenantless viewing.
|
||||
- **Verification Report**: A stored checklist result with per-check statuses, safe messages, evidence pointers, and “next steps” links.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Single entry point adoption)**: 100% of managed-tenant onboarding starts from the single onboarding entry point; legacy URLs return “not found” behavior.
|
||||
- **SC-002 (Time to first verification)**: A workspace admin can reach “verification started” within 3 minutes of opening onboarding (excluding external consent/approval wait time).
|
||||
- **SC-003 (No pre-activation tenant-scoped routing)**: Before activation, the wizard never generates tenant-scoped URLs; this is validated by regression tests.
|
||||
- **SC-004 (Authorization correctness)**: Non-members consistently receive deny-as-not-found behavior; members lacking capability receive 403 on action attempts; authorized users complete onboarding.
|
||||
- **SC-005 (Idempotency)**: For repeated Step 1 submissions with the same Entra Tenant ID in the same workspace, no duplicates are created and the user resumes the existing onboarding session.
|
||||
- **SC-006 (Secret safety)**: No secret material appears in UI, reports, notifications, logs, or audit events; validated by automated tests.
|
||||
- **SC-007 (Operational clarity)**: When verification is blocked, at least 90% of users can identify the reason category and next step from the report without opening a support ticket (measured via internal feedback or support tagging).
|
||||
184
specs/073-unified-managed-tenant-onboarding-wizard/tasks.md
Normal file
184
specs/073-unified-managed-tenant-onboarding-wizard/tasks.md
Normal file
@ -0,0 +1,184 @@
|
||||
---
|
||||
|
||||
description: "Tasks for Managed Tenant Onboarding Wizard V1 (Enterprise) (073)"
|
||||
---
|
||||
|
||||
# Tasks: Managed Tenant Onboarding Wizard V1 (Enterprise)
|
||||
|
||||
**Input**: Design documents from `specs/073-unified-managed-tenant-onboarding-wizard/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Required (Pest). Use `vendor/bin/sail artisan test --compact ...`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm baseline environment is ready for implementing and testing runtime behavior changes.
|
||||
|
||||
- [x] T001 Confirm Sail is running using docker-compose.yml (command: `vendor/bin/sail up -d`)
|
||||
- [x] T002 Run a baseline test subset using phpunit.xml and tests/ (command: `vendor/bin/sail artisan test --compact`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required by all user stories (capabilities, resumable session model, tenant status semantics).
|
||||
|
||||
- [x] T003 Define wizard capabilities (per-step/per-action) in app/Support/Auth/Capabilities.php
|
||||
- [x] T004 [P] Map wizard capabilities to roles (least privilege) in app/Services/Auth/WorkspaceRoleCapabilityMap.php
|
||||
- [x] T005 Implement server-side authorization checks for wizard actions in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (no role-string checks)
|
||||
- [x] T006 Ensure Tenant lifecycle supports `draft|onboarding|active|archived` in app/Models/Tenant.php
|
||||
- [x] T007 Update onboarding session schema to match data-model (safe state only) in app/Models/TenantOnboardingSession.php
|
||||
- [x] T008 Update onboarding session migration constraints for idempotency in database/migrations/2026_02_04_090010_update_tenant_onboarding_sessions_constraints.php
|
||||
- [x] T009 [P] Add foundational capability + tenant lifecycle tests in tests/Feature/Onboarding/OnboardingFoundationsTest.php
|
||||
|
||||
**Checkpoint**: Foundation ready — user story implementation can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Single entry point onboarding (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Provide `/admin/onboarding` as the sole onboarding entry point, redirect to workspace chooser if none selected, and implement Step 1 idempotent identification with strict 404/403 semantics.
|
||||
|
||||
**Independent Test**: Visit `/admin/onboarding` with and without a selected workspace, complete Step 1, and verify exactly one tenant/session is created and cross-workspace attempts behave as 404.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T010 [P] [US1] Add entry-point routing tests in tests/Feature/Onboarding/OnboardingEntryPointTest.php
|
||||
- [x] T011 [P] [US1] Add RBAC semantics tests (404 non-member, disabled UI + 403 action) in tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php
|
||||
- [x] T012 [P] [US1] Add idempotency + cross-workspace isolation tests in tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T013 [US1] Make `/admin/onboarding` the canonical wizard route in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (set slug; remove workspace route parameter dependency)
|
||||
- [x] T014 [US1] Resolve the current workspace from session context in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (redirect when missing; 404 when non-member)
|
||||
- [x] T015 [US1] Keep page visible for members without capability (disable controls + tooltip) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T016 [US1] Implement Step 1 inputs per spec (tenant name, environment, Entra Tenant ID, optional domain/notes) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T017 [US1] Implement Step 1 idempotent upsert + onboarding session resume (deny-as-not-found if tenant exists in another workspace) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T018 [US1] Ensure no pre-activation tenant-scoped links are generated in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
|
||||
### Remove legacy entry points (must be true 404, no redirects)
|
||||
|
||||
- [x] T019 [US1] Remove tenant registration surface from app/Providers/Filament/AdminPanelProvider.php (drop `->tenantRegistration(...)` if present)
|
||||
- [x] T020 [US1] Remove/404 legacy routes in routes/web.php (`/admin/new`, `/admin/register-tenant`, `/admin/managed-tenants/onboarding`)
|
||||
- [x] T021 [P] [US1] Add legacy route regression tests in tests/Feature/Onboarding/OnboardingLegacyRoutesTest.php
|
||||
|
||||
**Checkpoint**: US1 complete — `/admin/onboarding` is canonical, legacy entry points are 404, and Step 1 is safe + idempotent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Provider connection selection/creation (Priority: P2)
|
||||
|
||||
**Goal**: Allow selecting an existing workspace-owned provider connection or creating a new one, without ever re-displaying secrets.
|
||||
|
||||
**Independent Test**: Complete Step 2 in both modes (existing vs new), verify the onboarding session stores only non-secret state, and verify the provider connection is workspace-scoped and bound to the managed tenant by default.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T022 [P] [US2] Add connection selection/creation tests in tests/Feature/Onboarding/OnboardingProviderConnectionTest.php
|
||||
- [x] T023 [P] [US2] Add secret-safety regression tests in tests/Feature/Onboarding/OnboardingSecretSafetyTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T024 [US2] Implement workspace-owned ProviderConnection schema changes in database/migrations/2026_02_04_090020_make_provider_connections_workspace_owned.php
|
||||
- [x] T025 [US2] Update ProviderConnection model relationships + scoping in app/Models/ProviderConnection.php
|
||||
- [x] T026 [US2] Update ProviderConnection authorization for workspace scope in app/Policies/ProviderConnectionPolicy.php
|
||||
- [x] T027 [US2] Update ProviderConnection admin resource scoping in app/Filament/Resources/ProviderConnectionResource.php
|
||||
- [x] T028 [US2] Update Step 2 schema + persistence (no secrets in onboarding session state) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T029 [US2] Store provider_connection_id in onboarding session safe state in app/Models/TenantOnboardingSession.php
|
||||
|
||||
**Checkpoint**: US2 complete — Provider connections are workspace-owned, default-bound to one tenant, and secrets are never re-shown.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Verification + tenantless run viewing + activation (Priority: P3)
|
||||
|
||||
**Goal**: Start verification as an `OperationRun`, render DB-only reports with correct status mapping, and support tenantless viewing at `/admin/operations/{run}` without requiring selected workspace or tenant context.
|
||||
|
||||
**Independent Test**: Start verification from the wizard, dedupe active runs, open `/admin/operations/{run}` without a selected workspace, and enforce membership-based 404 semantics.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T030 [P] [US3] Add tenantless run viewer access tests in tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
- [x] T031 [P] [US3] Add verification start + dedupe tests in tests/Feature/Onboarding/OnboardingVerificationTest.php
|
||||
- [x] T032 [P] [US3] Add owner-only activation + override audit tests in tests/Feature/Onboarding/OnboardingActivationTest.php
|
||||
- [x] T052 [P] [US3] Add Graph contract registry coverage tests (organization + service principal permission probes) in tests/Unit/GraphContractRegistryOnboardingProbesTest.php
|
||||
|
||||
### Implementation — tenantless operation run viewer
|
||||
|
||||
- [x] T033 [US3] Add OperationRun workspace scoping fields + idempotency indexes in database/migrations/2026_02_04_090030_add_workspace_id_to_operation_runs_table.php
|
||||
- [x] T034 [US3] Update OperationRun model for workspace relationship + nullable tenant_id in app/Models/OperationRun.php
|
||||
- [x] T035 [US3] Update run identity/dedupe logic for tenantless runs in app/Services/OperationRunService.php
|
||||
- [x] T036 [US3] Exempt `/admin/operations/{run}` from forced workspace selection in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [x] T037 [US3] Prevent tenant auto-selection side effects for `/admin/operations/{run}` in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [x] T038 [US3] Authorize viewing runs by workspace membership (non-member → 404) in app/Policies/OperationRunPolicy.php
|
||||
- [x] T039 [US3] Implement tenantless `/admin/operations/{run}` viewer page + route with membership-based 404 semantics (app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, routes/web.php)
|
||||
|
||||
### Implementation — verification + report + activation
|
||||
|
||||
- [x] T053 [US3] Register onboarding verification probe endpoints in config/graph_contracts.php (organization + service principal permission lookups)
|
||||
- [x] T054 [US3] Refactor verification probe calls to resolve endpoints via GraphContractRegistry (no ad-hoc Graph paths; fail safe if contract missing) in app/Services/Graph/MicrosoftGraphClient.php and app/Services/Providers/ProviderGateway.php
|
||||
- [x] T040 [US3] Implement Step 3 start verification (OperationRun + queued job) with 403 on capability denial in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T041 [US3] Implement active-run dedupe (queued/running) and persist run IDs in app/Models/TenantOnboardingSession.php
|
||||
- [x] T042 [US3] Implement DB-only “Refresh” and status mapping (Blocked/Needs attention/Ready) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T055 [US3] Render a stored verification report in Step 3 (clear empty-state + secondary “Open run details” link) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T056 [US3] Enhance tenantless operation run viewer UI (context + failures + timestamps + refresh) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php and resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
|
||||
- [x] T057 [P] [US3] Add UI regression tests for wizard report and tenantless viewer details in tests/Feature/Onboarding/OnboardingVerificationTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
- [x] T043 [US3] Ensure “View run” links are tenantless `/admin/operations/{run}` via app/Support/OperationRunLinks.php
|
||||
- [x] T044 [US3] Implement optional bootstrap actions (per-action capability gating) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T045 [US3] Implement activation gating (owner-only) + blocked override reason + audit in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [x] T046 [US3] Add required audit events (stable action IDs; no secrets) in app/Services/Audit/WorkspaceAuditLogger.php
|
||||
|
||||
**Checkpoint**: US3 complete — verification is observable + deduped, runs are viewable tenantlessly, and activation is safe + audited.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Centralize badge semantics, harden RBAC-UX, and run formatting/tests.
|
||||
|
||||
- [x] T047 Add centralized badge mapping for onboarding/verification statuses in app/Support/Badges/Domains/
|
||||
- [x] T048 [P] Add badge mapping tests in tests/Feature/Badges/OnboardingBadgeSemanticsTest.php
|
||||
- [x] T049 [P] Add RBAC regression coverage for wizard actions in tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php
|
||||
- [x] T050 Run formatter on touched files using composer.json scripts (command: `vendor/bin/sail bin pint --dirty`)
|
||||
- [x] T051 Run targeted test suites using phpunit.xml (command: `vendor/bin/sail artisan test --compact tests/Feature/Onboarding tests/Feature/Operations`)
|
||||
|
||||
**Verification note**: Full suite re-run post-fixes is green (984 passed, 5 skipped).
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6)
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 (P1) depends on Phase 2 only.
|
||||
- US2 (P2) depends on US1 (managed tenant + onboarding session in place).
|
||||
- US3 (P3) depends on US2 (provider connection exists) and adds OperationRun viewer changes.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- [P] tasks can be executed in parallel (different files, minimal coupling).
|
||||
- Within each story: tests can be authored in parallel before implementation.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US1
|
||||
|
||||
Run in parallel:
|
||||
|
||||
- T010 (entry point routing tests) in tests/Feature/Onboarding/OnboardingEntryPointTest.php
|
||||
- T011 (RBAC semantics tests) in tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php
|
||||
- T012 (idempotency tests) in tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
MVP scope is US1 only: `/admin/onboarding` canonical entry point + Step 1 idempotent identification + strict 404/403 semantics + legacy routes 404 + tests.
|
||||
34
specs/074-verification-checklist/checklists/requirements.md
Normal file
34
specs/074-verification-checklist/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Verification Checklist Framework (Enterprise-Ready)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-03
|
||||
**Feature**: [specs/074-verification-checklist/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 (2026-02-03): Spec avoids framework specifics and focuses on contract + UX outcomes. Next step is planning to translate these requirements into a minimal set of deliverables (report schema, viewer, authorization semantics, audit events, and adoption points).
|
||||
@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"flow": "provider.connection.check",
|
||||
"generated_at": "2026-02-03T22:00:00Z",
|
||||
"identity": {
|
||||
"provider_connection_id": 123
|
||||
},
|
||||
"summary": {
|
||||
"overall": "blocked",
|
||||
"counts": {
|
||||
"total": 2,
|
||||
"pass": 1,
|
||||
"fail": 1,
|
||||
"warn": 0,
|
||||
"skip": 0,
|
||||
"running": 0
|
||||
}
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"key": "provider_connection.token_acquisition",
|
||||
"title": "Token acquisition works",
|
||||
"status": "fail",
|
||||
"severity": "high",
|
||||
"blocking": true,
|
||||
"reason_code": "authentication_failed",
|
||||
"message": "The app cannot acquire a token with the configured credentials.",
|
||||
"evidence": [
|
||||
{ "kind": "provider_connection_id", "value": 123 }
|
||||
],
|
||||
"next_steps": [
|
||||
{ "label": "Review connection credentials", "url": "/admin/provider-connections/123/edit" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "provider_connection.permissions",
|
||||
"title": "Required permissions are granted",
|
||||
"status": "pass",
|
||||
"severity": "info",
|
||||
"blocking": false,
|
||||
"reason_code": "ok",
|
||||
"message": "The configured app permissions meet the required baseline.",
|
||||
"evidence": [],
|
||||
"next_steps": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"flow": "provider.connection.check",
|
||||
"generated_at": "2026-02-03T22:00:00Z",
|
||||
"summary": {
|
||||
"overall": "ready",
|
||||
"counts": {
|
||||
"total": 1,
|
||||
"pass": 1,
|
||||
"fail": 0,
|
||||
"warn": 0,
|
||||
"skip": 0,
|
||||
"running": 0
|
||||
}
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"key": "provider_connection.health",
|
||||
"title": "Provider connection is healthy",
|
||||
"status": "pass",
|
||||
"severity": "info",
|
||||
"blocking": false,
|
||||
"reason_code": "ok",
|
||||
"message": "The provider connection passed all required health checks.",
|
||||
"evidence": [],
|
||||
"next_steps": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"flow": "provider.connection.check",
|
||||
"generated_at": "2026-02-03T22:00:00Z",
|
||||
"summary": {
|
||||
"overall": "running",
|
||||
"counts": {
|
||||
"total": 3,
|
||||
"pass": 1,
|
||||
"fail": 0,
|
||||
"warn": 0,
|
||||
"skip": 0,
|
||||
"running": 2
|
||||
}
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"key": "provider_connection.token_acquisition",
|
||||
"title": "Token acquisition works",
|
||||
"status": "running",
|
||||
"severity": "info",
|
||||
"blocking": false,
|
||||
"reason_code": "ok",
|
||||
"message": "Check is currently running.",
|
||||
"evidence": [],
|
||||
"next_steps": []
|
||||
},
|
||||
{
|
||||
"key": "provider_connection.permissions",
|
||||
"title": "Required permissions are granted",
|
||||
"status": "running",
|
||||
"severity": "info",
|
||||
"blocking": false,
|
||||
"reason_code": "ok",
|
||||
"message": "Check is currently running.",
|
||||
"evidence": [],
|
||||
"next_steps": []
|
||||
},
|
||||
{
|
||||
"key": "provider_connection.health",
|
||||
"title": "Provider connection is healthy",
|
||||
"status": "pass",
|
||||
"severity": "info",
|
||||
"blocking": false,
|
||||
"reason_code": "ok",
|
||||
"message": "The provider connection passed all required health checks.",
|
||||
"evidence": [],
|
||||
"next_steps": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user