Compare commits

...

3 Commits

Author SHA1 Message Date
05a604cfb6 Spec 076: Tenant Required Permissions (enterprise remediation UX) (#92)
Implements Spec 076 enterprise remediation UX for tenant required permissions.

Highlights
- Above-the-fold overview (impact + counts) with missing-first experience
- Feature-based grouping, filters/search, copy-to-clipboard for missing app/delegated permissions
- Tenant-scoped deny-as-not-found semantics; DB-only viewing
- Centralized badge semantics (no ad-hoc status mapping)

Testing
- Feature tests for default filters, grouping, copy output, and non-member 404 behavior.

Integration
- Adds deep links from verification checks to the Required permissions page.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #92
2026-02-05 22:08:51 +00:00
53dc89e6ef Spec 075: Verification Checklist Framework V1.5 (fingerprint + acknowledgements) (#93)
Implements Spec 075 (V1.5) on top of Spec 074.

Highlights
- Deterministic report fingerprint (sha256) + previous_report_id linkage
- Viewer change indicator: "No changes" vs "Changed" when previous exists
- Check acknowledgements (fail|warn|block) with capability-first auth, confirmation, and audit event
- Verify-step UX polish (issues-first, primary CTA)

Testing
- Focused Pest coverage for fingerprint, previous resolver, change indicator, acknowledgements, badge semantics, DB-only viewer guard.

Notes
- Viewing remains DB-only (no external calls while rendering).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #93
2026-02-05 21:44:19 +00:00
8e34b6084f 073-unified-managed-tenant-onboarding-wizard (#90)
Kontext / Ziel
Diese PR liefert den einzigen kanonischen Onboarding-Entry unter /admin/onboarding (workspace-first, tenantless bis zur Aktivierung) und ergänzt einen tenantless OperationRun-Viewer unter /admin/operations/{run} mit membership→404 Semantik.

Was ist enthalten?
Single entry point: /admin/onboarding ist der einzige Einstieg; Legacy Entry Points liefern echte 404 (keine Redirects).
Wizard v1 (Enterprise): idempotentes Identifizieren eines Managed Tenants (per Entra Tenant ID), resumable Session-Flow.
Provider Connection Step: Auswahl oder Erstellung, Secrets werden nie erneut gerendert / nicht in Session-State persistiert.
Verification als OperationRun: async/queued, DB-only Rendering im Wizard (keine Graph-Calls beim Rendern).
Tenantless Run Viewing: /admin/operations/{run} funktioniert ohne ausgewählten Workspace/Tenant, aber bleibt über Workspace-Mitgliedschaft autorisiert (non-member → 404).
RBAC-UX Semantik: non-member → 404, member ohne Capability → UI disabled + tooltip, server-side Action → 403.
Auditability: Aktivierung/Overrides sind auditierbar, stable action IDs, keine Secrets.
Tech / Version-Safety
Filament v5 / Livewire v4.0+ kompatibel.
Laravel 11+: Panel Provider Registrierung in providers.php (unverändert).
Tests / Format
vendor/bin/sail bin pint --dirty
Full suite: vendor/bin/sail artisan test --no-ansi → 984 passed, 5 skipped (exit 0)
Ops / Deployment Notes
Keine zusätzlichen Services vorausgesetzt.
Falls Assets registriert wurden: Deployment weiterhin mit php artisan filament:assets (wie üblich im Projekt).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #90
2026-02-04 23:30:55 +00:00
132 changed files with 12293 additions and 968 deletions

View File

@ -1,5 +1,6 @@
node_modules/
vendor/
coverage/
.git/
.DS_Store
Thumbs.db

View File

@ -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']);
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +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;
@ -143,6 +147,63 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->viewData(function (OperationRun $record): array {
$report = VerificationReportViewer::report($record);
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
$previousRunUrl = null;
if ($changeIndicator !== null) {
$tenant = 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))

View File

@ -20,6 +20,7 @@
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([
@ -633,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');
}

View File

@ -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'],

View 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(),
];
}
}

View File

@ -5,6 +5,7 @@
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportFingerprint;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
@ -31,6 +32,53 @@ public static function report(OperationRun $run): ?array
return $report;
}
public static function previousReportId(array $report): ?int
{
$previousReportId = $report['previous_report_id'] ?? null;
if (is_int($previousReportId) && $previousReportId > 0) {
return $previousReportId;
}
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
return (int) trim($previousReportId);
}
return null;
}
public static function fingerprint(array $report): ?string
{
$fingerprint = $report['fingerprint'] ?? null;
if (is_string($fingerprint)) {
$fingerprint = strtolower(trim($fingerprint));
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
return $fingerprint;
}
}
return VerificationReportFingerprint::forReport($report);
}
public static function previousRun(OperationRun $run, array $report): ?OperationRun
{
$previousReportId = self::previousReportId($report);
if ($previousReportId === null) {
return null;
}
$previous = OperationRun::query()
->whereKey($previousReportId)
->where('tenant_id', (int) $run->tenant_id)
->where('workspace_id', (int) $run->workspace_id)
->first();
return $previous instanceof OperationRun ? $previous : null;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];

View File

@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
return redirect()->route('admin.onboarding');
}
if ($tenantCount === 1) {

View File

@ -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);
}

View File

@ -8,12 +8,15 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderGateway;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -86,6 +89,87 @@ public function handle(
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$permissionService = app(TenantPermissionService::class);
$graphOptions = null;
if ($result->healthy) {
try {
$graphOptions = app(ProviderGateway::class)->graphOptions($connection);
} catch (\Throwable) {
$graphOptions = null;
}
}
$permissionComparison = $result->healthy
? ($graphOptions === null
? $permissionService->compare(
$tenant,
persist: false,
liveCheck: false,
useConfiguredStub: false,
)
: $permissionService->compare(
$tenant,
persist: true,
liveCheck: true,
useConfiguredStub: false,
graphOptions: $graphOptions,
))
: $permissionService->compare(
$tenant,
persist: false,
liveCheck: false,
useConfiguredStub: false,
);
$permissionRows = $permissionComparison['permissions'] ?? [];
$permissionRows = is_array($permissionRows) ? $permissionRows : [];
$inventory = null;
if (! $result->healthy) {
$inventory = [
'fresh' => false,
'reason_code' => $result->reasonCode ?? 'dependency_unreachable',
'message' => 'Provider connection check failed; permissions were not refreshed during this run.',
];
} elseif ($graphOptions === null) {
$inventory = [
'fresh' => false,
'reason_code' => 'provider_credential_missing',
'message' => 'Provider credentials were unavailable; observed permissions inventory was not refreshed during this run.',
];
} else {
$liveCheck = $permissionComparison['live_check'] ?? null;
$liveCheck = is_array($liveCheck) ? $liveCheck : [];
$reasonCode = is_string($liveCheck['reason_code'] ?? null) ? (string) $liveCheck['reason_code'] : 'dependency_unreachable';
$appId = is_string($liveCheck['app_id'] ?? null) && $liveCheck['app_id'] !== '' ? (string) $liveCheck['app_id'] : null;
$observedCount = is_numeric($liveCheck['observed_permissions_count'] ?? null)
? (int) $liveCheck['observed_permissions_count']
: null;
$message = ($liveCheck['succeeded'] ?? false) === true
? 'Observed permissions inventory refreshed successfully.'
: match ($reasonCode) {
'permissions_inventory_empty' => $appId !== null
? sprintf('No application permissions were detected for app id %s. Verify admin consent was granted for this exact app registration, then retry verification.', $appId)
: 'No application permissions were detected. Verify admin consent was granted for the configured app registration, then retry verification.',
default => 'Unable to refresh observed permissions inventory during this run. Retry verification.',
};
$inventory = [
'fresh' => ($liveCheck['succeeded'] ?? false) === true,
'reason_code' => $reasonCode,
'message' => $message,
'app_id' => $appId,
'observed_permissions_count' => $observedCount,
];
}
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
$report = VerificationReportWriter::write(
run: $this->operationRun,
checks: [
@ -124,6 +208,7 @@ public function handle(
], tenant: $tenant),
]],
],
...$permissionChecks,
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),

View File

@ -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);

View File

@ -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');

View File

@ -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.');
}

View File

@ -13,6 +13,26 @@ class TenantOnboardingSession extends Model
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 = [
@ -20,6 +40,24 @@ class TenantOnboardingSession extends Model
'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>
*/

View 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');
}
}

View File

@ -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();
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -24,6 +24,14 @@ class WorkspaceRoleCapabilityMap
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 => [
@ -31,11 +39,23 @@ class WorkspaceRoleCapabilityMap
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 => [

View File

@ -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';

View File

@ -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,

View File

@ -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) {

View File

@ -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,
];
}
}

View File

@ -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.
*

View File

@ -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'],
);
}
}

View File

@ -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.');
}
}

View File

@ -27,6 +27,7 @@ enum AuditActionId: string
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';
}

View File

@ -57,7 +57,7 @@ private static function sanitizeString(string $value): string
return self::REDACTED;
}
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
return self::REDACTED;
}

View File

@ -30,6 +30,22 @@ class Capabilities
// 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';
@ -45,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';

View File

@ -36,6 +36,7 @@ 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,

View File

@ -28,6 +28,7 @@ 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';

View File

@ -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(),
};
}
}

View 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();
}
}

View File

@ -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();

View File

@ -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);

View File

@ -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;
}
}

View 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,
];
}
}

View 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);
}
}

View File

@ -17,6 +17,23 @@ final class VerificationReportSanitizer
'set-cookie',
];
/**
* Evidence pointers must remain pointer-only. This allowlist is intentionally strict.
*
* @var array<int, string>
*/
private const ALLOWED_EVIDENCE_KINDS = [
'provider_connection_id',
'entra_tenant_id',
'organization_id',
'http_status',
'app_id',
'observed_permissions_count',
'missing_permission',
'permission_type',
'feature',
];
/**
* @return array<string, mixed>
*/
@ -39,6 +56,40 @@ public static function sanitizeReport(array $report): array
$sanitized['generated_at'] = $generatedAt;
}
if (array_key_exists('fingerprint', $report)) {
$fingerprint = $report['fingerprint'];
if (is_string($fingerprint)) {
$fingerprint = strtolower(trim($fingerprint));
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
$sanitized['fingerprint'] = $fingerprint;
}
}
}
if (array_key_exists('previous_report_id', $report)) {
$previousReportId = $report['previous_report_id'];
if ($previousReportId === null || is_int($previousReportId)) {
$sanitized['previous_report_id'] = $previousReportId;
} elseif (is_string($previousReportId)) {
$previousReportId = trim($previousReportId);
if ($previousReportId === '') {
$sanitized['previous_report_id'] = null;
} elseif (ctype_digit($previousReportId)) {
$sanitized['previous_report_id'] = (int) $previousReportId;
} else {
$previousReportId = self::sanitizeShortString($previousReportId, fallback: null);
if ($previousReportId !== null) {
$sanitized['previous_report_id'] = $previousReportId;
}
}
}
}
if (is_array($report['identity'] ?? null)) {
$identity = self::sanitizeIdentity((array) $report['identity']);
@ -164,8 +215,14 @@ private static function sanitizeChecks(array $checks): ?array
continue;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
$severityRaw = $check['severity'] ?? null;
if (! is_string($severityRaw)) {
continue;
}
$severity = strtolower(trim($severityRaw));
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
continue;
}
@ -210,6 +267,12 @@ private static function sanitizeEvidence(array $evidence): array
continue;
}
$kind = trim($kind);
if (! in_array($kind, self::ALLOWED_EVIDENCE_KINDS, true)) {
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
@ -217,7 +280,7 @@ private static function sanitizeEvidence(array $evidence): array
$value = $pointer['value'] ?? null;
if (is_int($value)) {
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
$sanitized[] = ['kind' => $kind, 'value' => $value];
continue;
}
@ -232,7 +295,7 @@ private static function sanitizeEvidence(array $evidence): array
continue;
}
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
}
return $sanitized;

View File

@ -6,7 +6,7 @@
final class VerificationReportSchema
{
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
/**
* @return array<string, mixed>|null
@ -78,6 +78,22 @@ public static function isValidReport(array $report): bool
}
}
if (array_key_exists('fingerprint', $report)) {
$fingerprint = $report['fingerprint'];
if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
return false;
}
}
if (array_key_exists('previous_report_id', $report)) {
$previousReportId = $report['previous_report_id'];
if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) {
return false;
}
}
return true;
}
@ -137,7 +153,13 @@ private static function isValidCheckResult(array $check): bool
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
if (! is_string($severity)) {
return false;
}
$severity = trim($severity);
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
return false;
}

View File

@ -35,6 +35,8 @@ public static function write(OperationRun $run, array $checks, array $identity =
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
$report = self::build($flow, $checks, $identity);
$report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run);
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
@ -75,6 +77,8 @@ public static function build(string $flow, array $checks, array $identity = []):
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow,
'generated_at' => now()->toISOString(),
'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks),
'previous_report_id' => null,
'summary' => [
'overall' => self::deriveOverall($normalizedChecks, $counts),
'counts' => $counts,
@ -98,6 +102,8 @@ private static function buildFallbackReport(string $flow): array
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow !== '' ? $flow : 'unknown',
'generated_at' => now()->toISOString(),
'fingerprint' => VerificationReportFingerprint::forChecks([]),
'previous_report_id' => null,
'summary' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
@ -161,14 +167,12 @@ private static function normalizeCheckStatus(mixed $status): string
private static function normalizeCheckSeverity(mixed $severity): string
{
if (! is_string($severity)) {
return VerificationCheckSeverity::Info->value;
return '';
}
$severity = strtolower(trim($severity));
return in_array($severity, VerificationCheckSeverity::values(), true)
? $severity
: VerificationCheckSeverity::Info->value;
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
}
private static function normalizeReasonCode(mixed $reasonCode): string

View File

@ -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',

View File

@ -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()),

View File

@ -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(),

View File

@ -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.
*

View File

@ -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(),
];
}
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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"/>

View File

@ -2,14 +2,95 @@
$report = isset($getState) ? $getState() : ($report ?? null);
$report = is_array($report) ? $report : null;
$run = $run ?? null;
$run = is_array($run) ? $run : null;
$fingerprint = $fingerprint ?? null;
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
$changeIndicator = $changeIndicator ?? null;
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
$previousRunUrl = $previousRunUrl ?? null;
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = $summary['counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $ack) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
continue;
}
$ackByKey[$checkKey] = $ack;
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
$check = is_array($check) ? $check : [];
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key === '') {
continue;
}
$statusValue = $check['status'] ?? null;
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
if (array_key_exists($key, $ackByKey)) {
$acknowledgedIssues[] = $check;
continue;
}
if ($statusValue === 'pass') {
$passed[] = $check;
continue;
}
if ($statusValue === 'fail' && $blocking) {
$blockers[] = $check;
continue;
}
if ($statusValue === 'fail') {
$failures[] = $check;
continue;
}
if ($statusValue === 'warn') {
$warnings[] = $check;
}
}
$sortChecks = static function (array $a, array $b): int {
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
};
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
@endphp
<div class="space-y-4">
@ -21,6 +102,9 @@
<div class="mt-1">
This run doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
</div>
@else
@php
@ -30,149 +114,377 @@
);
@endphp
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<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>
</div>
<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 ($checks === [])
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
No checks found in this report. Start verification again to generate a fresh report.
</div>
@else
<div class="space-y-3">
@foreach ($checks as $check)
@if ($changeIndicator !== null)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? $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,
);
$evidence = $check['evidence'] ?? [];
$evidence = is_array($evidence) ? $evidence : [];
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
$state = $changeIndicator['state'] ?? null;
$state = is_string($state) ? $state : null;
@endphp
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="flex cursor-pointer items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
@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>
@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>
</summary>
@if ($evidence !== [] || $nextSteps !== [])
<div class="mt-4 space-y-4">
@if ($evidence !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Evidence
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@foreach ($evidence as $pointer)
@php
$pointer = is_array($pointer) ? $pointer : [];
$kind = $pointer['kind'] ?? null;
$value = $pointer['value'] ?? null;
@endphp
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
<li>
<span class="font-medium">{{ $kind }}:</span>
<span>{{ is_int($value) ? $value : $value }}</span>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if ($nextSteps !== [])
<div>
<div class="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>
@endif
</details>
@endforeach
@endforeach
</div>
@endif
</div>
@endif
<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>

View File

@ -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 doesnt 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>

View File

@ -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>

View File

@ -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>

View File

@ -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 whats 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 doesnt 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>

View File

@ -21,7 +21,7 @@
type="button"
color="primary"
tag="a"
href="{{ route('admin.workspace.managed-tenants.onboarding', ['workspace' => $this->workspace->slug ?? $this->workspace->getKey()]) }}"
href="{{ route('admin.onboarding') }}"
>
Start onboarding
</x-filament::button>

View File

@ -66,7 +66,7 @@
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
return redirect()->to('/admin/onboarding');
}
if ($tenantCount === 1) {
@ -128,10 +128,21 @@
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
])
->get('/admin/w/{workspace}/managed-tenants/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
->name('admin.workspace.managed-tenants.onboarding');
->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',

View File

@ -1,7 +1,7 @@
# Specification Quality Checklist: Unified Managed Tenant Onboarding Wizard (073)
# Specification Quality Checklist: Managed Tenant Onboarding Wizard V1 (Enterprise)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-03
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-04
**Feature**: [spec.md](../spec.md)
## Content Quality
@ -31,6 +31,5 @@ ## Feature Readiness
## Notes
- All checklist items pass.
- The constitution-alignment paragraphs reference platform primitives (e.g., `OperationRun`) and domain integrations (e.g., Microsoft Graph) as required by this repositorys constitution.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
- 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`.

View File

@ -3,43 +3,39 @@ info:
title: TenantPilot — Managed Tenant Onboarding (073)
version: 0.1.0
description: |
Workspace-scoped onboarding wizard routes. These are UI endpoints (Filament/Livewire),
but documented here for contract clarity.
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/w/{workspace}/managed-tenants:
/admin/onboarding:
get:
summary: Managed tenants landing (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema:
type: string
summary: Managed tenant onboarding wizard (canonical entry point)
responses:
'200':
description: Renders managed tenants landing page.
'403':
description: Workspace member missing required capability (where applicable).
'404':
description: Workspace not found or user not a member (deny-as-not-found).
/admin/w/{workspace}/managed-tenants/onboarding:
get:
summary: Managed tenant onboarding wizard (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
'200':
description: Renders onboarding wizard page.
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 not a member (deny-as-not-found).
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:
@ -48,3 +44,19 @@ paths:
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).

View File

@ -1,32 +1,34 @@
# Onboarding Wizard — Action Contracts (073)
These are conceptual contracts for the wizards server-side actions (Livewire/Filament).
These are conceptual contracts for the wizards server-side actions (Filament / Livewire).
They define inputs/outputs and authorization semantics.
## Identify tenant
- **Purpose:** Upsert or resume a tenant onboarding session and ensure a single tenant record exists per `(workspace_id, entra_tenant_id)`.
- **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)
- `domain` (string|null)
- `primary_domain` (string|null)
- `notes` (string|null)
- **Outputs:**
- `tenant_id` (internal DB id)
- `managed_tenant_id` (internal DB id)
- `onboarding_session_id`
- `current_step`
- **Errors:**
- 404: workspace not found or actor not a workspace member
- 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:** Attach an existing default connection (if present) or create/select another connection for the tenant.
- **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`
- `is_default` (bool)
- **Errors:**
- 404: connection/tenant not in workspace scope
- 403: member missing capability
@ -43,6 +45,10 @@ ## Start verification
- 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 runs workspace; otherwise 404 (deny-as-not-found).
## Optional bootstrap actions
- **Purpose:** Start selected post-verify operations as separate runs.
@ -51,6 +57,23 @@ ## Optional bootstrap actions
- **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.

View File

@ -1,60 +1,82 @@
# Data Model — Unified Managed Tenant Onboarding Wizard (073)
# Data Model — Managed Tenant Onboarding Wizard V1 (Enterprise) (073)
## Entities
### Workspace
Existing entity. Onboarding is always initiated within a selected 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 be confirmed/extended):**
**Key fields (existing or to extend):**
- `id` (PK)
- `workspace_id` (FK to workspaces)
- `tenant_id` (string; Entra tenant ID) — specs `entra_tenant_id`
- `external_id` (string; globally unique route key used by Filament tenancy)
- `workspace_id` (FK workspaces)
- `tenant_id` (string; Entra Tenant ID) — specs `entra_tenant_id` (globally unique)
- `external_id` (string; Filament tenant route key; currently used in `/admin/t/{tenant}`)
- `name` (string)
- `domain` (string|null)
- `primary_domain` (string|null)
- `notes` (text|null)
- `environment` (string)
- `status` (string) — v1 lifecycle:
- `pending` (created / onboarding)
- `active` (ready)
- `archived` (no longer managed)
- `draft`
- `onboarding`
- `active`
- `archived`
**Indexes / constraints (design intent):**
- Unique: `(workspace_id, tenant_id)`
- Keep `external_id` globally unique (for `/admin/t/{tenant}` routing) and do **not** force it to equal `tenant_id`.
- Unique: `tenant_id` (global uniqueness; binds the tenant to exactly one workspace)
- `external_id` must remain globally unique for Filament tenancy routing
**State transitions:**
- `pending` → `active` after successful verification
- `active` → `archived` on soft-delete (existing behavior)
- `archived` → `active` on restore (existing behavior)
- `draft` → `onboarding` after identification is recorded
- `onboarding` → `active` on owner activation
- `active` → `archived` via archive/deactivate workflow
### ProviderConnection
### Provider Connection
Existing model: `App\Models\ProviderConnection`
Existing model today: `App\Models\ProviderConnection` (currently tenant-owned)
- Belongs to `Tenant`
- Contains `entra_tenant_id` (string) and default/active flags.
**Spec-aligned ownership model (design intent):**
### TenantOnboardingSession (new)
- 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.
New model/table to persist resumable onboarding state. Must never persist or return secrets.
**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)
- `tenant_id` (FK to tenants.id) — nullable until tenant is created, depending on wizard flow
- `entra_tenant_id` (string) — denormalized for upsert/idempotency before tenant exists
- `current_step` (string; e.g., `identify`, `connection`, `verify`, `bootstrap`, `complete`)
- `state` (jsonb/json) — safe fields only (no secrets)
- `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`
- `tenant_domain`
- `environment`
- `primary_domain`
- `notes`
- `selected_provider_connection_id`
- `verification_run_id` (OperationRun id)
- `bootstrap_run_ids` (array)
@ -65,20 +87,34 @@ ### TenantOnboardingSession (new)
**Constraints:**
- Unique: `(workspace_id, entra_tenant_id)`
- 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.
**State transitions:**
### Operation Run
- `in_progress` (implied by `completed_at = null`) → `completed` (`completed_at != null`)
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 runs 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` (`tenant_id`) must be a non-empty string; validate as GUID format if enforced elsewhere.
- Tenant name required to create tenant.
- ProviderConnection selection must belong to the same tenant/workspace.
- `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 scope: non-members denied as 404.
- Workspace member but missing onboarding capability: 403.
- Tenant scope: once tenant exists/selected, tenant membership rules apply as currently implemented.
- 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.

View File

@ -1,50 +1,45 @@
# Implementation Plan: Unified Managed Tenant Onboarding Wizard (073)
# Implementation Plan: Managed Tenant Onboarding Wizard V1 (Enterprise)
**Branch**: `073-unified-managed-tenant-onboarding-wizard` | **Date**: 2026-02-03 | **Spec**: specs/073-unified-managed-tenant-onboarding-wizard/spec.md
**Input**: Feature specification from `specs/073-unified-managed-tenant-onboarding-wizard/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
**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, resumable onboarding wizard for Managed Tenants that: (1) identifies/upserts a managed tenant within the current workspace, (2) attaches or configures a Provider Connection, (3) runs verification asynchronously as an `OperationRun` with sanitized outcomes, and (4) optionally kicks off bootstrap operations.
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.
Implementation approach: reuse existing primitives (`App\Models\Tenant`, Provider Connections, `provider.connection.check` operation type, workspace + tenant isolation middleware, canonical capability registries) and replace legacy tenant registration/redirect entry points with a single workspace-scoped wizard route.
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
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.x (Composer constraint: `^8.2`)
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
**Storage**: PostgreSQL (Sail) + SQLite in tests where applicable
**Testing**: Pest (via `vendor/bin/sail artisan test`)
**Target Platform**: Web app (Sail for local dev; container-based deploy on Linux)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: Onboarding UI renders DB-only; all Graph calls occur in queued work tracked by `OperationRun`; avoid N+1 via eager loading for any list/detail.
**Constraints**: Tenant isolation (404 vs 403 semantics); no secret material ever returned to the UI/logs; idempotent run-start and onboarding session resume; destructive-like actions require confirmation.
**Scale/Scope**: Workspace-scoped onboarding; expected low volume but high correctness/safety requirements.
**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.*
GATE RESULT: PASS (no planned constitution violations).
- Inventory-first: onboarding writes only tenant metadata + configuration pointers; no inventory/snapshot side effects.
- Read/write separation: onboarding creates/updates records and starts operations; all mutating actions are authorized, audited, and tested.
- Graph contract path: verification uses existing `GraphClientInterface` methods (e.g., `getOrganization()`), and runs only in queued jobs.
- Deterministic capabilities: use `App\Support\Auth\Capabilities` + `WorkspaceRoleCapabilityMap`; add a dedicated onboarding capability granted to Owner+Manager.
- RBAC-UX semantics: workspace membership enforced via `ensure-workspace-member`; tenant membership enforced via `EnsureFilamentTenantSelected` / `DenyNonMemberTenantAccess` with deny-as-not-found (404). Missing capability returns 403.
- Destructive confirmation: any archive/delete/credential-rotation actions involved in onboarding must be `->action(...)->requiresConfirmation()`.
- Run observability: verification + optional bootstrap actions start via `OperationRun` and enqueue only; monitoring pages remain DB-only.
- Data minimization: onboarding session stores only non-secret fields; run failures store reason codes + sanitized messages.
- BADGE-001: introduce/extend Managed Tenant status badges via `BadgeCatalog` domain mapping (no per-page mapping).
- 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
@ -52,112 +47,72 @@ ### Documentation (this feature)
```text
specs/073-unified-managed-tenant-onboarding-wizard/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── http.openapi.yaml
│ └── onboarding-actions.md
└── tasks.md
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Workspaces/
│ │ ├── ManagedTenantsLanding.php
│ │ └── (new) ManagedTenantOnboardingWizard.php
│ └── Pages/Tenancy/
│ └── RegisterTenant.php # legacy entry point to remove/disable
├── Http/Controllers/
│ └── TenantOnboardingController.php # legacy admin-consent helper; evaluate usage
├── Jobs/
│ └── ProviderConnectionHealthCheckJob.php # verification via OperationRun
├── Filament/Pages/
├── Filament/Resources/
├── Http/Middleware/
├── Models/
│ ├── Tenant.php
│ ├── ProviderConnection.php
│ └── (new) TenantOnboardingSession.php
└── Services/
├── Auth/
│ ├── WorkspaceCapabilityResolver.php
│ └── WorkspaceRoleCapabilityMap.php
├── Providers/
│ ├── ProviderOperationRegistry.php
│ └── ProviderGateway.php
└── Graph/
└── GraphClientInterface.php
├── Policies/
├── Services/
└── Support/
database/migrations/
├── (new) *_add_workspace_scoped_unique_tenant_id.php
└── (new) *_create_tenant_onboarding_sessions_table.php
routes/web.php
tests/Feature/
└── (new) ManagedTenantOnboardingWizardTest.php
```
**Structure Decision**: Laravel web application (monolith). Onboarding wizard is a Filament page mounted on a workspace-scoped route under `/admin/w/{workspace}/...` (no tenant context required to start).
**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
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
No constitution violations are anticipated for this feature.
## Phase 0 — Outline & Research (complete)
Outputs:
- `research.md`: decisions + rationale + alternatives (no unresolved clarifications).
Key research conclusions:
- Reuse `App\Models\Tenant` as “Managed Tenant” (no new base concept), but introduce `pending` status for the onboarding lifecycle.
- Replace legacy onboarding/registration routes (`/admin/register-tenant`, redirects under `/admin/managed-tenants/*`) with a single workspace-scoped onboarding wizard.
- Use existing provider verification operation type (`provider.connection.check`) executed via `ProviderConnectionHealthCheckJob` with `OperationRun` tracking.
## Phase 1 — Design & Contracts (complete)
Outputs:
- `data-model.md`: entities, fields, relationships, validation, state transitions.
- `contracts/*`: documented HTTP routes + action contracts (OpenAPI-style where applicable).
- `quickstart.md`: dev notes, env vars, how to run tests.
Design highlights:
- Data model
- Tenants: change status lifecycle to include `pending`, ensure `workspace_id` is NOT NULL + FK, and enforce global uniqueness of `tenant_id` (Entra tenant ID) bound to exactly one workspace.
- Onboarding sessions: new table/model for resumable state (strictly non-secret) keyed by `(workspace_id, tenant_id)`.
- Authorization
- Introduce a workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and map it to Owner+Manager via `WorkspaceRoleCapabilityMap`.
- Enforce server-side authorization for every mutation and operation-start; 404 for non-members and cross-workspace access; 403 for members missing capability.
- Runs
- Verification is a queued `OperationRun` using `provider.connection.check`.
- Optional bootstrap actions become separate `OperationRun` types (only if they exist in the ProviderOperationRegistry).
## Phase 2 — Implementation Plan (to be executed by /speckit.tasks)
This plan intentionally stops before creating `tasks.md`.
Proposed sequencing for tasks:
1) Introduce `TenantOnboardingSession` model + migration, and add workspace-scoped uniqueness for tenants.
2) Implement `ManagedTenantOnboardingWizard` page mounted at `/admin/w/{workspace}/managed-tenants/onboarding`.
3) Wire verification start to existing `ProviderConnectionHealthCheckJob` / `provider.connection.check` operation.
4) Remove/disable legacy entry points (`RegisterTenant`, redirect routes) and ensure “not found” behavior.
5) Add Pest feature tests for: 404 vs 403 semantics, idempotency, resumability, and sanitized run outcomes.
No constitution violations expected; changes are localized and gated by tests.

View File

@ -12,21 +12,21 @@ ## Local setup
## Using the wizard (expected flow)
1) Sign in to `/admin`.
2) Choose a workspace at `/admin/choose-workspace`.
3) Open `/admin/w/{workspace}/managed-tenants`.
4) Start onboarding at `/admin/w/{workspace}/managed-tenants/onboarding`.
5) Complete Identify → Connection → Verify (queued) → optional Bootstrap.
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 is tracked as an `OperationRun` (module `health_check`).
- 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 runs workspace.
## Tests
Run targeted tests (expected file name when implemented):
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`
- `vendor/bin/sail artisan test --compact --filter=Onboarding`
## Deploy / Ops

View File

@ -1,62 +1,67 @@
# Research — Unified Managed Tenant Onboarding Wizard (073)
# Research — Managed Tenant Onboarding Wizard V1 (Enterprise) (073)
This document resolves planning unknowns and records key implementation decisions.
This document resolves planning unknowns and records key implementation decisions aligned with the clarified spec.
## Decisions
### 1) Managed Tenant model = existing `Tenant`
- **Decision:** Treat the existing `App\Models\Tenant` as the “Managed Tenant” concept.
- **Rationale:** The admin panel tenancy, membership model, and most operational flows already key off `Tenant`.
- **Alternatives considered:**
- Introduce a new `ManagedTenant` model/table.
- Keep `Tenant` as-is and build onboarding as “just another page”.
- **Why rejected:** A second tenant-like model would duplicate authorization, routing, and operational conventions.
- **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) Workspace-scoped uniqueness + stable route key
### 2) Entra Tenant ID uniqueness = global, bound to one workspace
- **Decision:** Enforce uniqueness by `(workspace_id, tenant_id)` (where `tenant_id` is the Entra tenant ID), and ensure Filaments route tenant key stays globally unique.
- **Rationale:** The feature spec explicitly defines the uniqueness key, and cross-workspace safety requires first-class scoping.
- **Implementation note:** Today `tenants.external_id` is unique and is force-set to `tenant_id` in `Tenant::saving()`. If we allow the same `tenant_id` across workspaces, `external_id` must NOT be set to `tenant_id` anymore. Prefer a generated opaque stable `external_id` (UUID) and keep `tenant_id` strictly as the business identifier.
- **Alternatives considered:**
- Keep global uniqueness on `tenant_id` and keep using `external_id = tenant_id`.
- **Why rejected:** Conflicts with the clarified uniqueness key and complicates “deny-as-not-found” behavior via DB constraint errors.
- **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) Wizard route location = workspace-scoped (`/admin/w/{workspace}/...`)
### 3) Canonical onboarding entry point = `/admin/onboarding` (only)
- **Decision:** Mount onboarding at a workspace-scoped route: `/admin/w/{workspace}/managed-tenants/onboarding`.
- **Rationale:** This path is explicitly exempted from forced tenant selection in `EnsureFilamentTenantSelected`, allowing onboarding before a tenant exists.
- **Alternatives considered:**
- Tenant-scoped Filament routes (`/admin/t/{tenant}/...`).
- Reusing Filaments built-in tenant registration page (`tenantRegistration`).
- **Why rejected:** Tenant-scoped routes require a tenant to exist/selected; built-in registration is a legacy entry point we must remove.
- **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) Verification implementation = existing provider operation (`provider.connection.check`)
### 4) Tenantless operations viewer = existing `OperationRunResource` route `/admin/operations/{run}`
- **Decision:** Use `provider.connection.check` (module `health_check`) executed via `ProviderConnectionHealthCheckJob` as the onboarding verification run.
- **Rationale:** It already uses `OperationRun`, writes sanitized outcomes, and performs Graph calls off-request.
- **Alternatives considered:**
- New onboarding-specific operation type.
- **Why rejected:** Adds duplication without a clear benefit for v1.
- **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) Authorization surface = workspace capability (Owner+Manager)
### 5) `/admin/operations/{run}` must not require selected workspace or auto-switch
- **Decision:** Add a dedicated workspace capability for onboarding (e.g., `workspace_managed_tenant.onboard`) and grant it to workspace Owner and Manager in `WorkspaceRoleCapabilityMap`.
- **Rationale:** The spec requires Owner+Manager; existing workspace capabilities dont exactly match this (e.g., `WORKSPACE_MANAGE` is Owner-only).
- **Alternatives considered:**
- Check workspace role strings (`owner/manager`) directly.
- Reuse an unrelated capability like `WORKSPACE_MEMBERSHIP_MANAGE`.
- **Why rejected:** Constitution forbids role-string checks in feature code; reusing unrelated capability broadens authorization implicitly.
- **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) Legacy entry points = removed/404 (no redirects)
### 6) OperationRun authorization = workspace membership (non-member → 404)
- **Decision:** Remove/disable these entry points and ensure 404 behavior:
- `/admin/register-tenant` (Filament registration page)
- `/admin/managed-tenants*` legacy redirects
- `/admin/new` redirect
- `/admin/w/{workspace}/managed-tenants/onboarding` redirect stub
- **Rationale:** FR-001 requires wizard-only entry and “not found” behavior.
- **Decision:** Authorize viewing a run by checking membership in the runs 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. All technical unknowns required for planning are resolved.
- None for planning; implementation will need to reconcile existing DB schema and policies with the decisions above.

View File

@ -1,76 +1,84 @@
# Feature Specification: Unified Managed Tenant Onboarding Wizard (073)
# Feature Specification: Managed Tenant Onboarding Wizard V1 (Enterprise)
**Feature Branch**: `073-unified-managed-tenant-onboarding-wizard`
**Created**: 2026-02-03
**Created**: 2026-02-04
**Status**: Draft
**Input**: User description: "Single, unified onboarding wizard for Managed Tenants (create/attach connection, verify, optional bootstrap), removing all legacy entry points."
**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-03
### Session 2026-02-04
- Q: Which workspace roles can start the onboarding wizard? → A: Only `owner` and `manager`.
- Q: If Provider Connections already exist, what should Step 2 do? → A: Auto-use the existing default connection (and allow switching).
- Q: What is the canonical uniqueness key for a Managed Tenant? → A: Unique globally by `tenant_id` (Entra tenant ID) and bound to exactly one workspace.
- Q: Which Managed Tenant status values exist in v1? → A: `pending`, `active`, `archived`.
- Q: Who can resume an existing onboarding session? → A: Any workspace `owner/manager` with the onboarding capability (shared session per tenant).
- 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 Managed Tenant onboarding (Priority: P1)
### User Story 1 - Start onboarding from a single entry point (Priority: P1)
As a workspace member with the required capability, I can start a single guided onboarding flow that creates (or resumes) a Managed Tenant in the current workspace, so that the tenant is always created consistently and safely.
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 primary entry point and eliminates inconsistent/unsafe creation paths.
**Why this priority**: This is the foundation for all onboarding work and replaces fragmented legacy flows.
**Independent Test**: Can be fully tested by starting the onboarding in an empty workspace, completing step 1, and confirming a single Managed Tenant exists and is bound to that workspace.
**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** a user has selected a workspace and has permission to onboard tenants, **When** they complete “Identify Managed Tenant”, **Then** exactly one Managed Tenant record exists for that workspace and tenant identifier.
2. **Given** a user repeats the same step with the same tenant identifier, **When** they submit again, **Then** no duplicate Managed Tenant is created and the existing onboarding session is continued.
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 - Configure a connection and verify access (Priority: P2)
### User Story 2 - Attach or create a provider connection safely (Priority: P2)
As a workspace member with the required capability, I can configure (or attach) a Provider Connection for the Managed Tenant and trigger a verification run, so that connectivity and permissions are validated without exposing secrets.
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 validated connection, the tenant cannot be safely managed.
**Why this priority**: Without a valid connection, verification and activation cannot be completed safely.
**Independent Test**: Can be tested by completing the “Connection” step and starting a verification run, then asserting the run is created with the expected scope and that no secrets appear in run outputs.
**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** a Managed Tenant exists in the current workspace, **When** a user configures a connection, **Then** the system stores the connection as configured without ever showing stored secret material back to the user.
2. **Given** a user confirms they granted consent, **When** they trigger verification, **Then** a background verification run is started and is visible as “queued / running / succeeded / failed” with a sanitized outcome.
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 - Resume and complete onboarding (Priority: P3)
### User Story 3 - Verify access and review results without tenant-scoped context (Priority: P3)
As a workspace member, I can resume an incomplete onboarding session and complete optional bootstrap actions, so that interrupted onboarding does not create duplicates and finishes in a “ready” state.
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**: Real onboarding often pauses for consent/approvals; resumability reduces rework and errors.
**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 onboarding, leaving it incomplete, resuming, and finishing; then verifying the tenant is “ready” and optional actions create separate runs.
**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** onboarding was started but not completed, **When** the user returns later, **Then** they can resume at the correct step with previously entered (non-secret) state.
2. **Given** verification succeeded, **When** the user chooses optional bootstrap actions, **Then** each selected action starts its own background run and onboarding can still be completed.
---
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
- Cross-workspace isolation: a tenant identifier that exists in a different workspace must not be attachable or discoverable (deny-as-not-found).
- Missing capability: members without the required capability see disabled UI affordances, and server-side requests are denied.
- Roles and capabilities: `operator` and `readonly` members cannot start onboarding by default.
- Resume permissions: onboarding can be resumed by any authorized workspace `owner/manager` (not only the initiator).
- Verification failures: outcomes must be actionable (reason code + safe message) and never leak tokens/secrets.
- Idempotency: repeated submissions or refreshes must not create duplicate tenants, duplicate default connections, or a runaway number of active verification runs.
- Last-owner protections: demoting/removing the last owner (workspace or managed tenant) is blocked and recorded for audit.
- 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)*
@ -91,95 +99,91 @@ ## Requirements *(mandatory)*
- 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.
### Scope & Assumptions
**In scope (v1)**
- A single onboarding wizard to create or resume onboarding of a Managed Tenant within a selected workspace.
- Configure or attach a Provider Connection, guide consent, start verification runs, and optionally start bootstrap runs.
- Completion marks the tenant as ready/active and routes the user to the tenant details.
- Removal of all legacy UI entry points for creating/onboarding tenants (no redirects).
**Out of scope (v1)**
- User invitation workflows.
- Group-based auto-provisioning.
- Full compliance/evidence reporting.
- Cloud resource provisioning.
**Dependencies**
- Workspace selection/context and workspace membership.
- A managed-tenant concept bound to exactly one workspace.
- Provider Connections and secure credential storage.
- A run system to track verification and bootstrap actions.
- Audit logging and a canonical capability registry.
**Assumptions**
- Default policy: the onboarding initiator becomes workspace manager and managed-tenant owner (or the closest minimum-privilege equivalents).
- “Not found” behavior is used to avoid leaking the existence of out-of-scope tenants.
### Acceptance Coverage
The following acceptance coverage is required to treat the feature as complete:
- Legacy entry points removed (not found behavior).
- Workspace isolation enforced (cross-workspace attach/visibility prevented).
- Idempotency verified (no duplicates created by repeated submissions).
- Verification run creation and sanitized failure reporting.
- Last-owner protections enforced and auditable.
### Functional Requirements
- **FR-001 (Single entry point)**: System MUST provide exactly one UI flow to onboard a Managed Tenant (the onboarding wizard), and all other “add tenant” entry points MUST be removed and behave as “not found”.
- **FR-002 (Workspace-first enforcement)**: System MUST require an active workspace context for onboarding and tenant-scoped access.
- **FR-003 (Hard isolation)**: System MUST deny-as-not-found (404 semantics) when a Managed Tenant does not belong to the current workspace, including for attempts to attach an existing tenant identifier from another workspace.
- **FR-004 (Authorization semantics)**: System MUST enforce authorization server-side for all onboarding mutations and run-start actions. Non-member / not entitled to tenant scope MUST be treated as 404 semantics; a member lacking the required capability MUST be treated as 403 semantics. By default, only workspace `owner` and `manager` can start the onboarding wizard.
- **FR-005 (Capabilities-first)**: System MUST authorize via canonical capabilities (not role string comparisons in feature code).
- **FR-006 (Idempotent tenant identification)**: System MUST upsert tenant identification by a stable tenant identifier within the workspace, so repeating step 1 never creates duplicates.
- **FR-006a (Tenant uniqueness key)**: System MUST enforce a single Managed Tenant globally per `tenant_id` (Entra tenant ID) and bind it to exactly one workspace.
- **FR-007 (Onboarding session resumability)**: System MUST persist onboarding state (excluding secret material) so the flow can be resumed after interruption without data inconsistency.
- **FR-007a (Shared resumability)**: An onboarding session MUST be resumable by any authorized workspace `owner/manager` with the onboarding capability (not only the user who started it).
- **FR-008 (Connection handling)**: System MUST allow creating or attaching a Provider Connection during onboarding and MUST never display stored secret material back to users; UI MUST only show safe configuration indicators (e.g., configured yes/no, last rotation timestamp).
- **FR-008a (Default connection selection)**: If one or more Provider Connections already exist for the Managed Tenant, Step 2 MUST auto-select the default connection and MAY allow the user to switch to a different existing connection.
- **FR-009 (Verification as runs)**: System MUST start verification as a background run with clear status and a sanitized result (reason code + short safe message).
- **FR-010 (DB-only UI rendering)**: System MUST render onboarding UI using only stored data; any external calls required for verification MUST occur only in background work.
- **FR-011 (Operational clarity)**: System MUST display verification outcomes and missing requirements in a user-actionable way (what is missing, what to do next) without leaking sensitive details.
- **FR-012 (Optional bootstrap actions)**: System MUST support optional post-verify bootstrap actions that each start their own background run and do not block completion unless explicitly selected.
- **FR-013 (Completion state)**: System MUST mark the Managed Tenant as ready/active only after successful verification, and MUST redirect users to the Managed Tenant details view upon completion.
- **FR-013a (Status model)**: System MUST use a v1 Managed Tenant lifecycle with statuses: `pending` (created/onboarding), `active` (ready), `archived` (no longer managed).
- **FR-014 (Membership bootstrap)**: System MUST ensure the onboarding initiator receives the minimum required memberships in the workspace and the managed tenant scope according to policy (default: workspace manager + tenant owner).
- **FR-015 (Last-owner protections)**: System MUST block demotion/removal of the last owner at both workspace scope and managed tenant scope, and MUST record the blocked attempt for audit.
- **FR-016 (Auditability)**: System MUST record audit events for tenant creation, connection creation/rotation, verification start/result, membership changes, and last-owner blocks.
- **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 wizards “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 runs 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/customer context that owns memberships and one or more Managed Tenants.
- **Managed Tenant**: A managed Entra/Intune tenant, uniquely identified within a workspace by an external tenant identifier, with lifecycle status (e.g., pending/ready/archived).
- Uniqueness: exactly one globally per `tenant_id` (Entra tenant ID), bound to exactly one workspace.
- Status values (v1): `pending`, `active`, `archived`.
- **Provider Connection**: A technical connection configuration that enables access to a Managed Tenant; includes secure credentials/configuration metadata and enabled/default flags.
- **Onboarding Session**: A persistent record of onboarding progress and safe state to support resumability and idempotency.
- **Verification Run**: A background run that validates connectivity and required permissions and produces a sanitized outcome.
- **Membership (Workspace-scoped / Tenant-scoped)**: Defines who can see and operate within a workspace and on a specific managed tenant.
- **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 (Time-to-onboard)**: A workspace admin can complete the wizard up to starting verification in under 3 minutes (excluding external consent/approval waiting time).
- **SC-002 (Idempotency)**: Re-running any wizard step does not create duplicates (0 duplicate tenants per tenant identifier per workspace; 0 duplicate default connections per tenant).
- **SC-003 (Authorization correctness)**: For all onboarding endpoints/actions, non-members see no discoverability and get 404 semantics; members without capability get 403 semantics; authorized users can complete the flow.
- **SC-004 (Secret safety)**: No secrets/tokens are present in run outputs, notifications, audit entries, or error messages (validated by automated tests that assert redaction/sanitization behavior).
- **SC-005 (Operational clarity)**: When verification fails, users can identify the failure reason category (via reason code + safe message) and see the next step without contacting support.
### Badge Semantics (BADGE-001)
- Managed Tenant status badges MUST map from the canonical status set (`pending`, `active`, `archived`) using a centralized mapping (no ad-hoc per-page mapping).
- **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).

View File

@ -1,159 +1,184 @@
---
description: "Tasks for Unified Managed Tenant Onboarding Wizard (073)"
description: "Tasks for Managed Tenant Onboarding Wizard V1 (Enterprise) (073)"
---
# Tasks: Unified Managed Tenant Onboarding Wizard (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
---
- [X] T001 Confirm Sail is running and DB is reachable using docker-compose.yml (command: `vendor/bin/sail up -d`)
- [X] T002 Confirm baseline tests pass for the branch using phpunit.xml and tests/ (command: `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 (authz, data model, safety semantics).
**Purpose**: Shared primitives required by all user stories (capabilities, resumable session model, tenant status semantics).
- [X] T003 Add onboarding capability constant in app/Support/Auth/Capabilities.php
- [X] T004 Add onboarding capability mapping for Owner+Manager in app/Services/Auth/WorkspaceRoleCapabilityMap.php
- [X] T005 Implement Gate/Policy for onboarding authorization in app/Providers/AuthServiceProvider.php (enforce capabilities; no role-string checks)
- [X] T006 [P] Create TenantOnboardingSession model in app/Models/TenantOnboardingSession.php
- [X] T007 Create onboarding sessions migration in database/migrations/*_create_tenant_onboarding_sessions_table.php (unique workspace_id + tenant_id)
- [X] T008 Create tenant workspace binding migration in database/migrations/*_enforce_tenant_workspace_binding.php (ensure tenants.workspace_id is NOT NULL + FK; ensure tenants.tenant_id remains globally unique; deny cross-workspace duplicates)
- [X] T009 Verify tenant routing key strategy for v1: keep existing Filament tenant route-key stable (do NOT change external_id strategy in this feature); add a regression test that /admin/t/{tenant} continues to resolve the intended managed tenant
- [X] T010 [P] Add foundational authorization + data-model tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (capability known, mapping correct, migrations applied)
- [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**: Foundational complete — user story work can begin.
**Checkpoint**: Foundation ready — user story implementation can begin.
---
## Phase 3: User Story 1 — Start Managed Tenant onboarding (Priority: P1) 🎯 MVP
## Phase 3: User Story 1 — Single entry point onboarding (Priority: P1) 🎯 MVP
**Goal**: Start or resume a workspace-scoped onboarding wizard and create exactly one Managed Tenant per global-unique `tenant_id` (Entra tenant ID), bound to exactly one workspace.
**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**: Start onboarding in an empty workspace and complete “Identify Managed Tenant”; assert exactly one tenant exists and a session is created/resumed.
**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.
- [X] T011 [P] [US1] Add wizard page class in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Filament v5 / Livewire v4)
- [X] T012 [P] [US1] Add wizard view in resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php
- [X] T013 [US1] Register wizard route in routes/web.php at `/admin/w/{workspace}/managed-tenants/onboarding` with `ensure-workspace-member` middleware and 404 semantics for non-members
- [X] T014 [US1] Implement wizard mount + workspace loading in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (abort 404 for non-member, 403 for missing onboarding capability)
- [X] T015 [US1] Implement Step 1 “Identify Managed Tenant” upsert in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (transactional; idempotent by workspace_id + tenant_id; tenant status `pending`)
- [X] T015b [US1] Enforce cross-workspace uniqueness in Step 1: if a tenant with the same tenant_id exists in a different workspace, deny-as-not-found (404) and do not create/update anything
- [X] T015c [US1] Membership bootstrap: after tenant upsert, ensure the initiating user has a Managed Tenant membership of role owner (create if missing); never allow tenant to end up with zero owners
- [X] T016 [US1] Persist/resume onboarding session in app/Models/TenantOnboardingSession.php (no secrets in state)
- [X] T017 [US1] Add audit events for onboarding start/resume in app/Services/Audit/WorkspaceAuditLogger.php (or existing audit service) and call from wizard actions
- [X] T018 [P] [US1] Add happy-path tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (owner/manager can start; tenant created; session created)
- [X] T019 [P] [US1] Add negative auth tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (non-member gets 404; member without capability gets 403)
- [X] T020 [P] [US1] Add idempotency tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (repeat step does not create duplicates)
- [X] T020b [P] [US1] Add tests asserting membership bootstrap: newly created tenant has exactly one owner membership for the initiator; attempting to remove/demote the last owner is blocked (can be a minimal service/policy-level assertion)
- [X] T020c [P] [US1] Add tests asserting cross-workspace protection: if tenant_id exists under another workspace, the wizard returns 404 and does not reveal the existence of that tenant
### Tests (write first)
### Remove legacy entry points (required by FR-001)
- [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
- [X] T021 [US1] Remove tenant registration from app/Providers/Filament/AdminPanelProvider.php (drop `->tenantRegistration(...)`)
- [X] T022 [US1] Remove `/admin/register-tenant` route from routes/web.php (must behave as not found)
- [X] T023 [US1] Replace legacy onboarding redirects with 404 in routes/web.php (`/admin/managed-tenants`, `/admin/managed-tenants/onboarding`, `/admin/new`, workspace onboarding redirect stub)
- [X] T024 [US1] Remove RegisterTenant references in app/Filament/Pages/ChooseTenant.php and app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
- [X] T025 [P] [US1] Add regression tests in tests/Feature/ManagedTenantOnboardingWizardTest.php asserting legacy endpoints return 404 (no redirects)
### 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
**Checkpoint**: US1 complete — wizard is the only entry point; onboarding start is safe + idempotent.
### 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 — Configure a connection and verify access (Priority: P2)
## Phase 4: User Story 2 — Provider connection selection/creation (Priority: P2)
**Goal**: Attach or create a Provider Connection and start verification as an `OperationRun` without leaking secrets.
**Goal**: Allow selecting an existing workspace-owned provider connection or creating a new one, without ever re-displaying secrets.
**Independent Test**: Select/create connection, start verification, assert an OperationRun is created and job is dispatched; assert no secret material is returned.
**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.
- [X] T026 [US2] Implement Step 2 connection selection in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (auto-select default connection; allow switching)
- [X] T027 [US2] Implement connection creation path in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php using app/Models/ProviderConnection.php and app/Services/Providers/CredentialManager.php (never display stored secrets)
- [X] T028 [US2] Persist selected connection id in app/Models/TenantOnboardingSession.php `state` (non-secret)
- [X] T029 [US2] Implement “Start verification” action using app/Services/Providers/ProviderOperationStartGate.php with operation type `provider.connection.check`
- [X] T029b [US2] Enforce/verify dedupe: clicking “Start verification” twice while an active run exists must return the active OperationRun (no second run created); add a focused test (Bus::fake + assert single run)
- [X] T030 [US2] Ensure verification enqueues app/Jobs/ProviderConnectionHealthCheckJob.php and stores `operation_run_id` in onboarding session state
- [X] T031 [US2] Add “View run” navigation to app/Filament/Resources/OperationRunResource.php (link from wizard action notification)
- [X] T032 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for connection default selection + switching
- [X] T033 [P] [US2] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for verification run creation + job dispatch (Bus::fake)
- [X] T034 [P] [US2] Add secret-safety tests in tests/Feature/ManagedTenantOnboardingWizardTest.php (no secret fields appear in response/session/run failure summary)
### Tests (write first)
**Checkpoint**: US2 complete — verification is observable via OperationRun and secrets are safe.
- [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 — Resume and complete onboarding (Priority: P3)
## Phase 5: User Story 3 — Verification + tenantless run viewing + activation (Priority: P3)
**Goal**: Resume an onboarding session, run optional bootstrap actions, and complete onboarding to activate the tenant.
**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 onboarding, leave incomplete, resume as a different authorized owner/manager, complete verification + bootstrap, then mark tenant active.
**Independent Test**: Start verification from the wizard, dedupe active runs, open `/admin/operations/{run}` without a selected workspace, and enforce membership-based 404 semantics.
- [X] T035 [US3] Implement session resume logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (load by workspace_id + tenant_id; shared resumability)
- [X] T036 [US3] Implement Step gating in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (cannot complete until verification succeeded)
- [X] T037 [US3] Implement optional bootstrap actions in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start operations listed in app/Services/Providers/ProviderOperationRegistry.php)
- [X] T038 [US3] Persist bootstrap `operation_run_id`s in app/Models/TenantOnboardingSession.php `state`
- [X] T039 [US3] Implement completion: set tenant status `active`, set onboarding session `completed_at`, redirect to tenant dashboard (app/Filament/Pages/TenantDashboard.php)
- [X] T040 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for resume by different authorized actor
- [X] T041 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for completion and tenant status transition `pending``active`
- [X] T042 [P] [US3] Add tests in tests/Feature/ManagedTenantOnboardingWizardTest.php for bootstrap run creation (one OperationRun per selected action)
### Tests (write first)
**Checkpoint**: US3 complete — onboarding is resumable and completes safely.
- [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
- [X] T043 Add Managed Tenant status badge mapping via BadgeCatalog/BadgeRenderer in app/Support/Badges/* (BADGE-001) and add mapping test in tests/Feature/Badges/TenantStatusBadgeTest.php
- [X] T044 Verify/extend audit coverage for FR-016: use stable audit action IDs (enum/registry), ensure redaction, and add at least one concrete feature test asserting audit rows for onboarding start + verification start (no secrets in payload)
- [X] T045 Verify last-owner protections cover both workspace + tenant memberships; extend policies if needed in app/Policies/* and add regression tests in tests/Feature/Rbac/*
- [X] T046 Run formatter on touched files (command: `vendor/bin/sail bin pint --dirty`)
- [X] T047 Run targeted test suite for onboarding (command: `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`)
**Purpose**: Centralize badge semantics, harden RBAC-UX, and run formatting/tests.
### Post-spec hardening (Filament-native UX)
- [X] T048 Refactor onboarding page to a Filament-native Wizard schema (replace header-action modals + step cards; persist per-step progress; keep strict RBAC and existing action methods)
- [X] T049 Fix tenant identify UX: entering an existing tenant GUID must not surface a raw 404 modal; bind legacy unscoped tenants to the current workspace when safely inferable and add a regression test
- [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
### User Story completion order
### Phase Dependencies
1. US1 (P1) depends on Phase 2 only.
2. US2 (P2) depends on US1 (tenant/session + wizard scaffold).
3. US3 (P3) depends on US2 (verification state + run linking).
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6)
### Dependency graph
### User Story Dependencies
- Phase 1 → Phase 2 → US1 → US2 → US3 → Polish
- 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 execution examples
## Parallel Example: US1
### US1 parallel work
Run in parallel:
- [P] T011 and T012 can be implemented in parallel (page class vs blade view).
- [P] T018T020 can be written in parallel (distinct test cases).
### US2 parallel work
- [P] T032T034 can be written in parallel (selection tests vs run tests vs secret-safety tests).
### US3 parallel work
- [P] T040T042 can be written in parallel (resume tests vs completion tests vs bootstrap tests).
- 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)
## Implementation Strategy
- MVP scope is US1 only: wizard-only entry point + idempotent tenant identification + resumable session skeleton + required authorization semantics + tests.
### MVP First
MVP scope is US1 only: `/admin/onboarding` canonical entry point + Step 1 idempotent identification + strict 404/403 semantics + legacy routes 404 + tests.

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Verification Checklist Framework V1.5
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Reviewed against template used in [specs/074-verification-checklist/spec.md](../../074-verification-checklist/spec.md). No open clarifications remain.

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/acknowledge-check.request.schema.json",
"title": "AcknowledgeVerificationCheckRequest",
"type": "object",
"additionalProperties": false,
"required": [
"report_id",
"check_key",
"ack_reason"
],
"properties": {
"report_id": {
"type": ["string", "integer"]
},
"check_key": {
"type": "string",
"minLength": 1
},
"ack_reason": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"expires_at": {
"description": "Optional informational expiry timestamp.",
"type": ["string", "null"],
"format": "date-time"
}
}
}

View File

@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-check-acknowledgement.schema.json",
"title": "VerificationCheckAcknowledgement",
"type": "object",
"additionalProperties": false,
"required": [
"report_id",
"check_key",
"ack_reason",
"acknowledged_at",
"acknowledged_by"
],
"properties": {
"report_id": {
"description": "OperationRun id that contains the report.",
"type": ["string", "integer"]
},
"check_key": {
"type": "string",
"minLength": 1
},
"ack_reason": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"expires_at": {
"description": "Informational only in v1.5.",
"type": ["string", "null"],
"format": "date-time"
},
"acknowledged_at": {
"type": "string",
"format": "date-time"
},
"acknowledged_by": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": { "type": ["string", "integer"] },
"name": { "type": ["string", "null"] },
"email": { "type": ["string", "null"] }
}
}
}
}

View File

@ -0,0 +1,147 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-report.v1_5.schema.json",
"title": "VerificationReportV1_5",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"flow",
"generated_at",
"fingerprint",
"previous_report_id",
"summary",
"checks"
],
"properties": {
"report_id": {
"description": "Canonical report identifier. In v1.5 this is the OperationRun id.",
"type": ["string", "integer"]
},
"schema_version": {
"type": "string",
"description": "Version of the verification report schema (SemVer, major 1).",
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
},
"flow": {
"type": "string",
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
},
"previous_report_id": {
"description": "Previous report id for the same identity (nullable).",
"type": ["string", "integer", "null"]
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"identity": {
"type": "object",
"description": "Scope identifiers for what is being verified.",
"additionalProperties": true
},
"fingerprint": {
"description": "Deterministic SHA-256 hash (lowercase hex) of normalized check outcomes.",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["overall", "counts"],
"properties": {
"overall": {
"type": "string",
"enum": ["ready", "needs_attention", "blocked", "running"],
"description": "Overall state derived from check results."
},
"counts": {
"type": "object",
"additionalProperties": false,
"required": ["total", "pass", "fail", "warn", "skip", "running"],
"properties": {
"total": { "type": "integer", "minimum": 0 },
"pass": { "type": "integer", "minimum": 0 },
"fail": { "type": "integer", "minimum": 0 },
"warn": { "type": "integer", "minimum": 0 },
"skip": { "type": "integer", "minimum": 0 },
"running": { "type": "integer", "minimum": 0 }
}
}
}
},
"checks": {
"type": "array",
"minItems": 0,
"items": {
"$ref": "#/$defs/CheckResult"
}
}
},
"$defs": {
"CheckResult": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"title",
"status",
"severity",
"blocking",
"reason_code",
"message",
"evidence",
"next_steps"
],
"properties": {
"key": { "type": "string" },
"title": { "type": "string" },
"status": {
"type": "string",
"enum": ["pass", "fail", "warn", "skip", "running"]
},
"severity": {
"description": "Must be included for fingerprint determinism; may be empty string.",
"type": "string",
"enum": ["", "info", "low", "medium", "high", "critical"]
},
"blocking": { "type": "boolean" },
"reason_code": { "type": "string" },
"message": { "type": "string" },
"evidence": {
"type": "array",
"items": { "$ref": "#/$defs/EvidencePointer" }
},
"next_steps": {
"type": "array",
"description": "Navigation-only CTAs (links) in v1.",
"items": { "$ref": "#/$defs/NextStep" }
}
}
},
"EvidencePointer": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "value"],
"properties": {
"kind": { "type": "string" },
"value": {
"description": "Safe pointer value (ID/masked string/hash).",
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
}
}
},
"NextStep": {
"type": "object",
"additionalProperties": false,
"required": ["label", "url"],
"properties": {
"label": { "type": "string" },
"url": { "type": "string" }
}
}
}
}

View File

@ -0,0 +1,114 @@
# Data Model: Verification Checklist Framework V1.5 (075)
**Date**: 2026-02-05
**Phase**: Phase 1 (Design)
**Status**: Draft (design-complete for implementation planning)
---
## Existing Entity (reference)
### OperationRun (existing)
**Purpose**: Canonical operational record. Verification reports are stored in `operation_runs.context.verification_report` (JSONB).
**Key fields (relevant)**:
- `id`
- `tenant_id`
- `workspace_id`
- `type` (verification flow identifier)
- `run_identity_hash` (identity hash used for active dedupe + identity matching)
- `status`
- `context` (JSONB)
**Verification report storage**:
- `context.verification_report` (JSON object)
---
## New Persistent Entity
### VerificationCheckAcknowledgement (new table)
**Table name**: `verification_check_acknowledgements`
**Purpose**: First-class governance record that a failing/warning check is acknowledged for a specific report (report == operation run).
**Fields**:
- `id` (primary key)
- `tenant_id` (FK or scalar; used for tenant-scoped filtering and isolation checks)
- `workspace_id` (FK or scalar)
- `operation_run_id` (FK to `operation_runs.id`) — the “report”
- `check_key` (string)
- `ack_reason` (string, max 160)
- `expires_at` (timestamp, nullable) — informational only in v1.5
- `acknowledged_at` (timestamp)
- `acknowledged_by_user_id` (FK to `users.id`)
- `created_at`, `updated_at`
**Uniqueness constraint (required)**:
- unique `(operation_run_id, check_key)`
**Indexes (recommended)**:
- `(tenant_id, workspace_id, operation_run_id)`
- `(operation_run_id)`
**Validation rules**:
- `ack_reason`: required, string, length ≤ 160
- `expires_at`: optional, must be a valid timestamp, should be >= acknowledged_at (implementation may enforce)
**State transitions**:
- Immutable per report/check in v1.5: create once; no update/delete/unack flows.
---
## Contracted Document (stored in JSON)
### VerificationReport (JSON in `OperationRun.context.verification_report`)
**Purpose**: Structured, versioned report of verification results used by the DB-only viewer.
**Identity**:
- `report_id`: `operation_runs.id`
- `previous_report_id`: previous run id for same identity (nullable)
**New v1.5 fields**:
- `fingerprint` (string; lowercase hex; SHA-256)
- `previous_report_id` (nullable integer/uuid depending on `OperationRun` PK type)
**Existing core fields (from 074, reference)**:
- `schema_version` (SemVer string; major `1`)
- `flow` (verification flow identifier; aligns with `OperationRun.type`)
- `generated_at` (timestamp)
- `summary` (counts + overall outcome)
- `checks[]` (flat array) including:
- `key`
- `title`
- `status` (`pass|fail|warn|skip|running`)
- `severity` (`info|low|medium|high|critical` or empty string)
- `blocking` (boolean)
- `reason_code` (string)
- safe evidence pointers
- `next_steps[]` (navigation-only links)
**Fingerprint normalization input** (strict):
- Flatten all checks across `checks[]`.
- Sort by `check.key`.
- Contribute the stable tuple string:
- `key|status|blocking|reason_code|severity`
- `severity` must always be present (missing normalized to empty string).
---
## Derived/Computed View Data (not persisted)
### Change indicator
Computed in the viewer by comparing:
- current `verification_report.fingerprint`
- previous `verification_report.fingerprint`
States:
- no previous report → no indicator
- fingerprints match → “No changes since previous verification”
- fingerprints differ → “Changed since previous verification”

View File

@ -0,0 +1,150 @@
# Implementation Plan: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
**Branch**: `075-verification-v1_5` | **Date**: 2026-02-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/075-verification-v1-5/spec.md`
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
## Summary
- Extend the existing 074 verification report system with deterministic **fingerprints** and a **previous report** link so the viewer can show “Changed / No changes”.
- Introduce per-check **acknowledgements** as first-class records (unique per report + check) with explicit confirmation and audit logging, without changing outcomes (“no greenwashing”).
- Update the Verify step UX to be operator-ready: issues-first tabs, centralized badge semantics (BADGE-001), and exactly one primary CTA depending on state.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 (Filament v5 requires Livewire v4.0+)
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`) + a new acknowledgement table
**Testing**: Pest (PHPUnit)
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
**Project Type**: web
**Performance Goals**: DB-only viewer renders quickly from stored JSON; fingerprint computation is linear in number of checks (typical report ≤ 50 checks)
**Constraints**:
- Viewer + Verify step are DB-only at render time (no outbound HTTP / Graph / job dispatch).
- All mutations require server-side authorization (RBAC-UX) and explicit confirmation.
- Status-like UI must use centralized badge semantics (BADGE-001).
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding and provider workflows
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first, snapshots-second: PASS (report/ack UX; no inventory semantics changed).
- Read/write separation: PASS (viewer remains read-only; acknowledgements are explicit mutations with confirmation + audit + tests).
- Graph contract path: PASS (viewer is DB-only; no new Graph calls added by this feature).
- Deterministic capabilities: PASS (capabilities remain centrally registered; no raw strings).
- RBAC-UX: PASS (non-member access is 404; member missing capability is 403; server-side enforcement required).
- Run observability: PASS (verification remains an `OperationRun`; dedupe while active is unchanged).
- Data minimization: PASS (no secrets/tokens; audit payload excludes `ack_reason`).
- Badge semantics (BADGE-001): PASS (no new status values; existing verification badge domains remain canonical).
## Project Structure
### Documentation (this feature)
```text
specs/075-verification-v1-5/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
database/
└── migrations/
resources/
routes/
tests/
```
**Structure Decision**: Single Laravel web app with Filament v5 panel. This feature extends verification report writer/viewer, adds an acknowledgement persistence model + migration, and refactors the Verify step UI in Filament.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Goals covered:
- Confirm canonical storage approach for report metadata (keep report in `operation_runs.context`).
- Define deterministic fingerprint algorithm and previous report resolution rules.
- Define acknowledgement persistence strategy and capability naming reconciliation.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- [data-model.md](./data-model.md)
- [contracts/](./contracts/)
- [quickstart.md](./quickstart.md)
Design focus:
- Report metadata: add `fingerprint` and `previous_report_id` inside the report JSON.
- Previous report resolution: match identity exactly (type/flow + tenant + workspace + provider connection), with `NULL` connection matching only `NULL`.
- Acknowledgements: first-class DB table keyed by `(operation_run_id, check_key)`; immutable in v1.5.
- Filament UX: issues-first tabs and “one primary CTA” rule; acknowledgements via `Action::make(...)->action(...)` + `->requiresConfirmation()`.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Data
- Migration: create `verification_check_acknowledgements` table with unique `(operation_run_id, check_key)`.
- Model: `VerificationCheckAcknowledgement` with tenant/workspace scoping.
### Report writer / viewer
- Extend the report writer to compute and store `fingerprint` and `previous_report_id` (report_id is the run id).
- Extend the DB-only viewer to load previous report (when present) and compute the “changed/no-change” indicator.
- Ensure the viewer consumes acknowledgements (DB lookup) and groups “Acknowledged issues” separately.
### Authorization + audit
- Capability: add `tenant_verification.acknowledge` to the canonical capability registry and map to roles.
- Server-side auth: non-members 404; members without capability 403 for acknowledgement.
- Audit: add a new stable action ID (e.g. `verification.check_acknowledged`) with minimal metadata and no `ack_reason`.
### Filament UX
- Verify step: implement the issues-first layout and strict “exactly one primary CTA” rule.
- Actions: acknowledgement requires confirmation; navigation-only links remain links-only.
- BADGE-001: continue to use centralized badge domains for statuses and summary.
### Tests (Pest)
- Fingerprint determinism: same normalized inputs → same hash; severity-only differences → different hash.
- Previous report linking: identity match includes provider connection (`NULL` only matches `NULL`).
- RBAC-UX: non-member gets 404; member without capability gets 403 on acknowledgement.
- Audit: acknowledgement emits correct action id + minimal metadata (assert `ack_reason` absent).
- Viewer DB-only: no outbound HTTP during render/hydration.
## Constitution Check (Post-Design)
Re-check result: PASS. Design keeps report viewing DB-only, introduces a single tenant-scoped mutation with confirmation + audit, preserves RBAC-UX semantics, and maintains BADGE-001 centralized badge rendering.

View File

@ -0,0 +1,47 @@
# Quickstart: Verification Checklist Framework V1.5 (075)
This quickstart is for developers implementing and validating Spec 075.
## Prerequisites
- Docker + Sail
- A seeded workspace + tenant and a user that can access the tenant plane (`/admin/t/{tenant}`)
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
- Run migrations: `vendor/bin/sail artisan migrate`
## Run verification (expected UX)
After implementation, the Verify surface should behave like:
1. Navigate to the tenant-scoped Verify step (onboarding or equivalent).
2. If no active run exists, the single primary CTA is **Start verification**.
3. If a run is active, the single primary CTA is **Refresh results**.
4. Results default to the **Issues** tab with blockers/failures/warnings ordered first.
## Acknowledge a check (expected UX)
After implementation:
1. On a `fail` or `warn` check card, click **Acknowledge**.
2. Confirmation modal appears (required).
3. Submit a short reason (≤ 160 chars) and optional expiry (informational only).
4. The acknowledgement displays (who/when/reason) and the issue moves into the “Acknowledged issues” group.
## Authorization expectations
- Non-members attempting to access tenant-scoped verification pages: deny-as-not-found (404).
- Tenant members without `tenant_verification.acknowledge`: acknowledgement attempts fail with 403.
## Run the focused test suite
Once tests are implemented:
- Run only the spec-related tests: `vendor/bin/sail artisan test --compact --filter=Verification` (or point at the specific test file(s)).
## Formatting
- Format only changed files before finalizing: `vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,119 @@
# Research: Verification Checklist Framework V1.5 (075)
**Date**: 2026-02-05
**Phase**: Phase 0 (Foundational Research)
**Status**: Complete
---
## Decisions
### D-075-001 — Canonical storage for report + metadata
**Decision**: Store the verification report (including `fingerprint` and `previous_report_id`) inside `operation_runs.context.verification_report` (JSONB), consistent with 074.
**Rationale**:
- Viewer surfaces must be DB-only at render time (constitution: Operations / Run Observability Standard).
- `OperationRun` is already the canonical operational record and stable viewer entry point.
- Adds supportability metadata without introducing a new top-level report table.
**Alternatives considered**:
- Dedicated `verification_reports` table: rejected for v1.5 to avoid new query/index surfaces; revisit if we need global querying across reports.
---
### D-075-002 — Report identity + “previous report” resolution
**Decision**: Resolve `previous_report_id` by querying the most recent earlier `OperationRun` whose **run identity** matches exactly (flow/type, tenant, workspace, provider connection where applicable).
**Rationale**:
- The existing `OperationRunService::ensureRunWithIdentity()` + `run_identity_hash` already defines the dedupe boundary.
- Matches the specs clarified rule: `provider_connection_id` must match exactly; `NULL` only matches `NULL`.
**Alternatives considered**:
- Match previous runs by only `tenant_id + workspace_id + type` and then filter in PHP: rejected due to ambiguity and risk of cross-connection mixing.
---
### D-075-003 — Report ID semantics
**Decision**: Treat the `OperationRun` ID as the report identifier in UX and contracts (`report_id == operation_run_id`).
**Rationale**:
- The report is attached to the run; the run is the stable, tenant-scoped canonical record.
- Avoids a second identifier for the same “verification execution artifact”.
**Alternatives considered**:
- Generate a separate report UUID inside the JSON: rejected as it adds indirection without benefits in v1.5.
---
### D-075-004 — Fingerprint algorithm
**Decision**: Use SHA-256 over a deterministic normalization of check outcomes:
- flatten checks
- sort by `check.key`
- contribute `key|status|blocking|reason_code|severity` where `severity` is always present (missing → empty)
Store as lowercase hex.
**Rationale**:
- Deterministic across environments.
- Treats severity-only changes as meaningful (per clarified requirement).
**Alternatives considered**:
- Hash the full report JSON: rejected (unstable ordering, non-semantic fields like timestamps).
---
### D-075-005 — Per-check acknowledgements persistence
**Decision**: Create a first-class table `verification_check_acknowledgements` keyed by `(operation_run_id, check_key)` with a unique constraint.
**Rationale**:
- Acknowledgements are governance metadata and must be queryable and auditable.
- Unique per report/check is enforced by the DB.
**Alternatives considered**:
- Store acknowledgements inside `operation_runs.context`: rejected as it complicates update semantics and auditability, and risks “report mutation” appearing like a changed verification outcome.
---
### D-075-006 — Capability naming reconciliation
**Decision**: Introduce a dedicated canonical capability `tenant_verification.acknowledge` in the capability registry and map it in the role → capability map.
**Rationale**:
- Keeps the feature spec requirement literal and avoids overloading “findings” semantics.
- Preserves the constitution rule that capabilities are centrally registered (no raw strings).
**Alternatives considered**:
- Reuse existing `tenant_findings.acknowledge`: rejected because this feature is specifically verification-report scoped, and we want the permission surface to remain explicit.
---
### D-075-007 — Audit action identifier + payload minimization
**Decision**: Add a stable audit action ID for acknowledgements (e.g. `verification.check_acknowledged`) and emit it on successful acknowledgement. Audit metadata is minimal and MUST NOT include `ack_reason`.
**Rationale**:
- Acknowledgement is a write mutation; constitution requires audit logging.
- Spec explicitly excludes `ack_reason` from audit payload; it remains only in the acknowledgement record.
**Alternatives considered**:
- Reuse `verification.completed`: rejected because it conflates verification execution with governance mutation.
---
### D-075-008 — Filament UI implementation constraints
**Decision**: Implement the “Verify step” UX changes in Filament v5 (Livewire v4) using:
- DB-only viewer helper (no external calls)
- centralized badge domains (BADGE-001)
- mutation via Filament `Action::make(...)->action(...)` with `->requiresConfirmation()`
**Rationale**:
- Aligns with Filament v5 patterns and constitution rules.
**Alternatives considered**:
- Publish/override Filament internal views: rejected; prefer render hooks + CSS hooks as needed.

View File

@ -0,0 +1,225 @@
# Feature Specification: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
**Feature Branch**: `075-verification-v1_5`
**Created**: 2026-02-05
**Status**: Draft
**Input**: User description: "Extend verification checklist framework with report fingerprint + previous report change indicator, per-check acknowledgements with audit/confirmation, and issues-first operator-ready verify-step UX."
## Goal
V1.5 extends the V1 verification checklist framework with two enterprise-critical additions while keeping scope intentionally small:
1) **Supportability / determinism**: show whether results changed since the previous verification for the same identity.
2) **Governance**: allow explicit, auditable acknowledgement of known issues per failing check.
3) **Enterprise UX completeness**: make the Verify step operator-ready (issues-first, one clear primary action, technical details secondary).
## Clarifications
### Session 2026-02-05
- Q: How is “block” represented on checks? → A: No new status; a “Blocker” is `status=fail` with `blocking=true`.
- Q: Should `severity` be part of the fingerprint? → A: Yes; include `severity` always (normalize missing to empty) to keep hashing deterministic and to treat severity-only changes as “Changed”.
- Q: Should `ack_reason` be included in the audit event payload? → A: No; keep audit metadata minimal and store the reason only in the acknowledgement record.
- Q: How should `provider_connection_id` be treated when resolving `previous_report_id`? → A: Match exactly; `NULL` only matches `NULL` (no cross-connection mixing).
- **Report shape (canonical, inherited from 074)**: Persist reports as the existing V1 JSON shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`). V1.5 adds `fingerprint` + `previous_report_id` at the top level. No `sections[]` array is stored.
- **Idempotency (inherited)**: Deduplication applies only while a run is active (`queued` / `running`). Once `completed` / `failed`, starting verification creates a new run.
- **Viewing (inherited)**: Viewing is DB-only; rendering MUST NOT perform external calls.
- **Evidence (inherited)**: Evidence is limited to safe pointers only; no secrets (no tokens/claims/headers/raw payloads).
- **Next steps (inherited)**: Navigation-only links (no server-side “fix it” actions from the viewer).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Operator can tell “nothing changed” (Priority: P1)
As an operator, I can immediately see whether the current verification findings are unchanged compared to the previous verification for the same identity, so I can avoid unnecessary re-diagnosis.
**Why this priority**: This is the fastest path to supportability: it reduces repeated analysis and makes troubleshooting deterministic.
**Independent Test**: Create two reports for the same identity with identical normalized check outcomes; confirm the viewer indicates “No changes since previous verification”.
**Acceptance Scenarios**:
1. **Given** a report with a previous report available, **When** I open the viewer, **Then** I see a clear indicator “Changed” or “No changes”.
2. **Given** the current report has the same fingerprint as the previous report, **When** I open the viewer, **Then** I see “No changes since previous verification”.
---
### User Story 2 - Owner/Manager can acknowledge a known issue (Priority: P1)
As an owner/manager, I can acknowledge a failing/warning/blocking check with a short reason (and optionally an expiry) so the team can see that the risk is known, evaluated, and accepted.
**Why this priority**: Acknowledgements provide governance without masking risk; they improve shared context and auditability.
**Independent Test**: With and without the acknowledgement capability, attempt to acknowledge a failing check; assert correct authorization (403) and that an audit event is recorded for the successful path.
**Acceptance Scenarios**:
1. **Given** a check in status `fail` / `warn` (including failing blockers where `blocking=true`), **When** I acknowledge it with a reason, **Then** the UI shows who acknowledged it, when, and the reason.
2. **Given** I do not have the acknowledgement capability, **When** I attempt to acknowledge a check, **Then** the server returns 403 and the UI does not offer the acknowledgement action.
---
### User Story 3 - Verify step is operator-ready (issues-first) (Priority: P1)
As a workspace member, I see issues-first results with clear next steps and exactly one primary action (start or refresh), so I can remediate quickly without hunting through technical details.
**Why this priority**: The Verify step is a high-frequency operator surface; clarity and deterministic states reduce time-to-resolution.
**Independent Test**: Seed a report with blockers and a running state; confirm the default tab and “one primary CTA” rule is enforced in both completed and running scenarios.
**Acceptance Scenarios**:
1. **Given** a report with blockers, **When** I open the Verify step/viewer, **Then** the Issues tab is the default and blockers are at the top.
2. **Given** a run is active, **When** I open the Verify step/viewer, **Then** the primary action is “Refresh results” and technical links are secondary.
---
### Edge Cases
- No previous report exists for an identity → no “changed/no-change” indicator is shown.
- Run is active but no report is available yet → UI shows a clear “running, results will appear” explanation (no empty states without guidance).
- Partial report while running → partial results render with a “Partial results” label.
- Unknown check keys or reason codes → UI degrades gracefully, showing status and message without breaking.
- Acknowledgement attempted for non-acknowledgeable status (e.g., `pass`) → request is rejected and UI does not offer it.
## Out of Scope
- Diff/compare UI between reports
- Server-side fixes initiated from the viewer
- Undo / unacknowledge acknowledgements (V1.5 acknowledgements are immutable per report)
- Complex staleness/TTL semantics (fresh/stale/expired)
- Global dashboards / cross-tenant reporting
- Export features (PDF/JSON) as a product feature
- Live polling (V1.5 uses manual refresh)
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds new tenant-scoped mutations (acknowledgements) and new report metadata. It MUST include explicit confirmation, audit logging for mutations, tenant isolation, and tests.
**Constitution alignment (RBAC-UX):** Tenant-scoped routes MUST preserve deny-as-not-found (404) for non-members, and use 403 for members missing a capability. UI visibility is not authorization; server-side enforcement is required.
**Constitution alignment (BADGE-001):** Status-like badges MUST use centralized mapping semantics; no ad-hoc UI mappings.
### Functional Requirements
- **FR-075-001 — Report fingerprint**: Each verification report MUST store a deterministic `fingerprint` derived from normalized check outcomes.
**Normalization rule (deterministic):**
- Flatten all check results across `report.checks[]`
- Sort by stable `check.key`
- For each check, contribute a stable string using: `key | status | blocking | reason_code | severity`
- `severity` MUST be included always; if the source report omits it, normalize to an empty string
- The fingerprint MUST be a stable cryptographic hash of the joined contributions, stored as a fixed-length lowercase hex string.
- **FR-075-002 — Previous report link**: Each report MUST store `previous_report_id` (nullable) that points to the most recent earlier report for the same **verification identity**.
**Identity match** MUST include:
- flow
- workspace
- tenant
- provider connection (`provider_connection_id`) matched exactly; `NULL` only matches `NULL`
- **FR-075-003 — Change indicator**: When a previous report exists, the viewer MUST show:
- “No changes since previous verification” if `fingerprint` matches
- “Changed since previous verification” otherwise
- **FR-075-004 — Per-check acknowledgements (first-class)**: The system MUST allow acknowledging checks with status `fail` / `warn`.
An acknowledgement MUST record:
- reason (max 160 characters)
- acknowledged timestamp
- acknowledged-by user
- optional expiry timestamp
Acknowledgements MUST be unique per (report, check key). Expiry, when provided, is informational only in V1.5 and MUST NOT introduce automatic staleness/TTL behavior.
- **FR-075-005 — Acknowledgement does not change outcomes**: Acknowledging MUST NOT change:
- the check status
- the report summary status/outcome
- the run outcome
- **FR-075-006 — Acknowledgement allowed conditions**: Acknowledgement MUST only be possible for checks whose status is in `{fail, warn}`. It MUST NOT be available for passing/green checks.
A check is considered a **Blocker** when `status=fail` and `blocking=true`; blockers are acknowledgeable under the same `{fail, warn}` rule (no separate `block` status exists).
- **FR-075-007 — Acknowledgement authorization (capability-first)**: Acknowledgement MUST require the capability `tenant_verification.acknowledge` as defined in the canonical capability registry.
RBAC UX semantics:
- non-member / not entitled to tenant scope → 404
- member without acknowledgement capability → 403
- members with tenant scope but without acknowledgement capability can still view reports (view remains read-only)
- **FR-075-007A — Viewing authorization semantics preserved (inherited)**: Viewing tenant-scoped verification pages (Verify step + report viewer) MUST preserve V1 semantics:
- non-member / not entitled to tenant scope → 404
- member with tenant scope → can view
- capability checks apply to mutations only (start verification, acknowledgement)
- **FR-075-008 — Confirmation + audit required**: Acknowledgement is a mutation and MUST require explicit user confirmation and MUST emit an audit event.
- **FR-075-009 — Audit event metadata (minimal)**: The audit event for acknowledgement MUST include minimally:
- workspace, tenant, run, report, flow
- check key and reason code
- acknowledged-by user
It MUST NOT include `ack_reason`, secrets, tokens, or raw payloads.
- **FR-075-010 — DB-only viewing guard (inherited)**: Rendering the viewer and the Verify step MUST NOT trigger external calls.
- **FR-075-011 — Centralized badge semantics (BADGE-001)**: All check-status badges and summary-status badges used by V1.5 MUST use the centralized badge mapping registry.
- **FR-075-012 — Verify step enterprise UX (normative)**: The Verify step/viewer MUST follow an issues-first layout and deterministic UI states:
**Structure**
- Always-visible summary card
- Tabs: Issues (default), Passed, Technical details
**DB-only hint**
- The summary surface MUST include a clear hint that viewing is read-only and performs no external calls.
**Primary action rule (strict)**
- Exactly one primary call-to-action is shown at any time
- “Start verification” and “Refresh results” MUST NOT both be primary simultaneously
**Issues tab ordering**
1) Blockers (not acknowledged)
2) Failures (not acknowledged)
3) Warnings (not acknowledged)
4) Acknowledged issues (collapsed group)
**Next steps rendering**
- Max 2 navigation-only links per issue card
- “Open run details” MUST appear only in Technical details (not in issue cards)
**Technical details**
- Secondary surface that can show identifiers (run/report IDs), fingerprint, and previous report link
- No raw payloads/tokens/full error bodies
### Key Entities *(include if feature involves data)*
- **Verification Identity**: The stable identifiers that define “what is being verified” (flow, workspace, tenant, and optional provider connection).
- **Verification Report**: A structured record of verification outcomes for a run.
- **Report Fingerprint**: A deterministic hash representing normalized check outcomes.
- **Previous Report**: The immediately preceding report for the same identity.
- **Check Acknowledgement**: A governance record that an issue is known/accepted (who/when/reason/optional expiry) without altering the check outcome.
### Assumptions
- A verification run/report concept already exists from V1.
- The system has an audit log mechanism capable of recording acknowledgement actions.
- Manual refresh is acceptable (no polling required).
### Dependencies
- Spec 074 (Verification Checklist Framework V1)
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-075-001 (Supportability)**: With a previous report present, operators can determine “changed vs no changes” within 10 seconds in 95% of tested sessions.
- **SC-075-002 (Governance)**: 100% of successful acknowledgements create an audit log record with minimal metadata and no sensitive content.
- **SC-075-003 (UX determinism)**: The Verify step renders exactly one primary CTA in all tested UI states (not started, running with/without report, completed).
- **SC-075-004 (Authorization correctness)**: Non-members receive 404 for tenant-scoped access routes in 100% of tests; members without acknowledgement capability receive 403 for acknowledgement attempts in 100% of tests.
- **SC-075-005 (No greenwashing)**: Acknowledging an issue never changes check status or the report summary in any tested scenario.
```

View File

@ -0,0 +1,172 @@
---
description: "Task breakdown for Spec 075 (Verification Checklist Framework V1.5)"
---
# Tasks: Verification Checklist Framework V1.5 (075)
**Input**: Design documents from `/specs/075-verification-v1-5/`
**Tests**: REQUIRED (Pest)
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Align feature artifacts with the existing 074 verification implementation (report shape, DB-only viewing constraints).
- [X] T001 Reconcile v1.5 report contract to match the V1 report shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`) + v1.5 fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json
- [X] T002 [P] Confirm viewer surfaces are DB-only using existing guard helpers in tests/Support/AssertsNoOutboundHttp.php (helper availability + correct usage patterns)
- [X] T003 [P] Identify all report viewer templates to update: resources/views/filament/components/verification-report-viewer.blade.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all user stories (schema/sanitization, stable fingerprint, previous report resolution).
**⚠️ CRITICAL**: Complete this phase before implementing US1/US2/US3.
- [X] T004 Update report schema to allow v1.5 metadata fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSchema.php
- [X] T005 Update report sanitizer to preserve v1.5 metadata fields (`fingerprint`, `previous_report_id`) and preserve empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSanitizer.php
- [X] T006 [P] Add a deterministic fingerprint helper in app/Support/Verification/VerificationReportFingerprint.php (flatten `checks[]`; normalize missing `severity` to empty string, not `info`)
- [X] T007 Add a previous-report resolver helper in app/Support/Verification/PreviousVerificationReportResolver.php
- [X] T008 [P] Add or update verification badge mapping tests in tests/Feature/Badges/ to cover all v1.5-used status-like values (BADGE-001)
**Checkpoint**: Schema + sanitizer accept v1.5 fields; fingerprint + previous-report resolver are available for use.
---
## Phase 3: User Story 1 — Operator can tell “nothing changed” (Priority: P1) 🎯 MVP
**Goal**: Persist a deterministic `fingerprint` + `previous_report_id` on each report, and show “Changed / No changes” when a previous report exists.
**Independent Test**: Create two completed verification runs for the same identity with identical normalized outcomes; confirm viewer indicates “No changes since previous verification”.
### Tests for User Story 1 (write first)
- [X] T009 [P] [US1] Add fingerprint determinism unit tests in tests/Feature/Verification/VerificationReportFingerprintTest.php (including missing severity → empty string, and severity-only changes → different hash)
- [X] T010 [P] [US1] Add previous report identity matching tests (provider_connection_id exact match; NULL matches NULL) and a regression proving cross-connection runs dont match when run_identity_hash includes provider_connection_id in tests/Feature/Verification/PreviousVerificationReportResolverTest.php
### Implementation for User Story 1
- [X] T011 [US1] Compute and persist report fingerprint in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/VerificationReportFingerprint.php)
- [X] T012 [US1] Resolve and persist previous_report_id during write in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/PreviousVerificationReportResolver.php + run_identity_hash; verify all verification run start paths include provider_connection_id in identityInputs)
- [X] T013 [P] [US1] Extend DB-only report viewer helper to expose v1.5 metadata in app/Filament/Support/VerificationReportViewer.php
- [X] T014 [US1] Add change-indicator computation for viewer surfaces in app/Filament/Support/VerificationReportChangeIndicator.php
**Checkpoint**: Report JSON includes `fingerprint` + `previous_report_id`; viewer can derive Changed/No changes.
---
## Phase 4: User Story 2 — Owner/Manager can acknowledge a known issue (Priority: P1)
**Goal**: Acknowledge `fail` / `warn` checks per report with confirmation + audit, without changing check outcomes.
**Independent Test**: Attempt to acknowledge a failing check (a) as non-member → 404, (b) as member without capability → 403, (c) with capability → record created + audit logged.
### Tests for User Story 2 (write first)
- [X] T015 [P] [US2] Add acknowledgement authorization + audit tests in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php (404 non-member, 403 missing capability, persists optional expires_at; audit metadata includes check_key + reason_code and excludes ack_reason)
### Implementation for User Story 2
- [X] T016 [US2] Create migration for verification_check_acknowledgements table (includes optional expires_at; informational only) in database/migrations/*_create_verification_check_acknowledgements_table.php
- [X] T017 [P] [US2] Create model in app/Models/VerificationCheckAcknowledgement.php
- [X] T018 [P] [US2] Create factory for acknowledgements in database/factories/VerificationCheckAcknowledgementFactory.php
- [X] T019 [US2] Implement acknowledgement creation service in app/Services/Verification/VerificationCheckAcknowledgementService.php (server-side authorization via Gate/policy; validate status ∈ {fail,warn}; validate optional expires_at; enforce unique per (operation_run_id, check_key))
- [X] T020 [P] [US2] Register capability constant tenant_verification.acknowledge in app/Support/Auth/Capabilities.php
- [X] T021 [P] [US2] Map tenant_verification.acknowledge to tenant roles in app/Services/Auth/RoleCapabilityMap.php
- [X] T022 [P] [US2] Add audit action id for acknowledgement in app/Support/Audit/AuditActionId.php (e.g. verification.check_acknowledged)
- [X] T023 [US2] Emit audit event with minimal metadata via app/Services/Audit/WorkspaceAuditLogger.php from the acknowledgement path (MUST include: tenant_id, operation_run_id/report_id, flow, check_key, reason_code; MUST NOT include ack_reason)
**Checkpoint**: Acknowledgements are persisted, authorized, confirmed in UI (next story), and audited with minimized metadata.
---
## Phase 5: User Story 3 — Verify step is operator-ready (issues-first) (Priority: P1)
**Goal**: Issues-first view, centralized badge semantics (BADGE-001), DB-only hint, and exactly one primary CTA depending on state.
**Independent Test**: Seed a run with blockers while completed and while running; confirm Issues is default, ordering rules hold, and one-primary-CTA rule holds.
### Tests for User Story 3 (write first)
- [X] T024 [P] [US3] Add Verify-step CTA and ordering tests in tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
- [X] T025 [P] [US3] Add DB-only render guard test coverage for Verify surfaces in tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
### Implementation for User Story 3
- [X] T026 [US3] Enforce “exactly one primary CTA” logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start vs refresh)
- [X] T027 [US3] Refactor Verify-step report view to issues-first tabs + ordering + DB-only hint in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [X] T028 [US3] Add per-check acknowledgement action UI with confirmation in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Action::make(...)->action(...)->requiresConfirmation())
- [X] T029 [US3] Wire acknowledgement UI to service + RBAC semantics (404 non-member, 403 missing capability; server-side enforcement required) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [X] T030 [US3] Update the Monitoring viewer to match v1.5 UX rules (issues-first tabs: Issues default, Passed, Technical details; ordering; next-steps max 2) in resources/views/filament/components/verification-report-viewer.blade.php
- [X] T031 [P] [US3] Show change indicator + previous report link in technical details (no raw payloads) in resources/views/filament/components/verification-report-viewer.blade.php
**Checkpoint**: Verify UX is deterministic, issues-first, and operator-ready across onboarding and monitoring surfaces.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Hardening, formatting, and regression coverage.
- [X] T032 [P] Ensure acknowledgement does not mutate check status/summary in app/Support/Verification/VerificationReportWriter.php and cover with assertions in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php
- [X] T033 [P] Add redaction regression checks for new v1.5 fields (fingerprint/previous_report_id) in tests/Feature/Verification/VerificationReportRedactionTest.php
- [X] T034 [P] Run Pint on changed files via vendor/bin/sail bin pint --dirty
- [X] T035 Run focused test suite via vendor/bin/sail artisan test --compact --filter=Verification
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: start immediately
- **Foundational (Phase 2)**: blocks all user stories
- **User Stories (Phase 35)**:
- US1 depends on Phase 2
- US2 depends on Phase 2
- US3 depends on Phase 2 and benefits from US1 + US2 completion
- **Polish (Phase 6)**: after US1US3
### User Story Dependencies (Graph)
- **US1 (Fingerprint + previous report + changed indicator)** → enables technical details and “Changed/No changes” banner in US3
- **US2 (Acknowledgements)** → enables “Acknowledged issues” grouping and action UX in US3
- **US3 (Verify UX)** → integrates outputs of US1 + US2 into operator surface
---
## Parallel Execution Examples
### US1
- Run in parallel:
- T009 (fingerprint determinism tests) + T010 (previous resolver tests)
- T013 (viewer helper exposure) can proceed while T011/T012 land
### US2
- Run in parallel:
- T017 (model) + T018 (factory) + T020 (capability constant) + T021 (role mapping) + T022 (audit action id)
### US3
- Run in parallel:
- T024 (UX tests) + T025 (DB-only tests)
- T027 (onboarding blade refactor) + T030 (monitoring viewer refactor)
---
## Implementation Strategy
### MVP First (US1)
1. Phase 1 → Phase 2
2. Implement US1 (Phase 3)
3. Validate: run T035 and confirm “No changes since previous verification” path
### Incremental Delivery
1. US1 (supportability) → US2 (governance) → US3 (operator UX)
2. After each story, run story-specific tests plus `vendor/bin/sail artisan test --compact --filter=Verification`

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Tenant Required Permissions Page (Enterprise Remediation UX)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-05
**Feature**: [specs/076-permissions-enterprise-ui/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items validated against [specs/076-permissions-enterprise-ui/spec.md](../spec.md).
- Domain terms like “Microsoft Graph permissions” are treated as product-domain vocabulary, not implementation detail; the spec avoids describing external call mechanics.

View File

@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RequiredPermissionsPageViewModel",
"type": "object",
"required": ["tenant", "overview", "permissions", "filters"],
"properties": {
"tenant": {
"type": "object",
"required": ["id", "external_id", "name"],
"properties": {
"id": {"type": "integer"},
"external_id": {"type": "string"},
"name": {"type": "string"}
}
},
"overview": {
"type": "object",
"required": ["overall", "counts", "feature_impacts"],
"properties": {
"overall": {"type": "string", "enum": ["ready", "needs_attention", "blocked", "running"]},
"counts": {
"type": "object",
"required": ["missing_application", "missing_delegated", "present", "error"],
"properties": {
"missing_application": {"type": "integer", "minimum": 0},
"missing_delegated": {"type": "integer", "minimum": 0},
"present": {"type": "integer", "minimum": 0},
"error": {"type": "integer", "minimum": 0}
}
},
"feature_impacts": {
"type": "array",
"items": {
"type": "object",
"required": ["feature", "missing", "required_application", "required_delegated", "blocked"],
"properties": {
"feature": {"type": "string"},
"missing": {"type": "integer", "minimum": 0},
"required_application": {"type": "integer", "minimum": 0},
"required_delegated": {"type": "integer", "minimum": 0},
"blocked": {"type": "boolean"}
}
}
}
}
},
"permissions": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "type", "features", "status"],
"properties": {
"key": {"type": "string"},
"type": {"type": "string", "enum": ["application", "delegated"]},
"description": {"type": ["string", "null"]},
"features": {"type": "array", "items": {"type": "string"}},
"status": {"type": "string", "enum": ["granted", "missing", "error"]},
"details": {"type": ["object", "null"], "additionalProperties": true}
}
}
},
"filters": {
"type": "object",
"required": ["status", "type", "features", "search"],
"properties": {
"status": {"type": "string", "enum": ["missing", "present", "all"]},
"type": {"type": "string", "enum": ["application", "delegated", "all"]},
"features": {"type": "array", "items": {"type": "string"}},
"search": {"type": "string"}
}
}
}
}

View File

@ -0,0 +1,59 @@
# Verification Report — Clustered Checks (Spec 076)
This feature extends the existing verification report (`operation_runs.context.verification_report`) with additional *clustered* checks derived from required-permission coverage.
## Source of truth
- Schema: `app/Support/Verification/VerificationReportSchema.php` (v1.x)
- Writer: `app/Support/Verification/VerificationReportWriter.php`
## Proposed check keys (stable)
The report should contain 57 checks. Suggested keys:
1) `provider.connection.check` (existing)
2) `permissions.admin_consent`
3) `permissions.directory_groups`
4) `permissions.intune_configuration`
5) `permissions.intune_apps`
6) `permissions.intune_rbac_assignments`
7) `permissions.scripts_remediations` (optional / skip when irrelevant)
## Check computation rules
Each check must produce the fields required by the schema:
- `key`, `title`, `status` (`pass|fail|warn|skip|running`)
- `severity` (`info|warning|critical|...`)
- `blocking` (bool)
- `reason_code`, `message`
- `evidence[]` pointers
- `next_steps[]` links
### Permission-derived checks
- Input: permission comparison rows from `TenantPermissionService::compare(...persist: false, liveCheck: false)`.
- Status rules:
- `pass`: all mapped permissions are granted
- `fail`: at least one mapped permission is missing and the cluster is marked blocking
- `warn`: optional/non-blocking missing permissions (if introduced later)
- `skip`: no mapped permissions apply (or feature is irrelevant)
### Evidence pointers
Recommended evidence pointers:
- `{ kind: 'missing_permission', value: '<permission_key>' }`
- `{ kind: 'permission_type', value: 'application|delegated' }`
- `{ kind: 'feature', value: '<feature_key>' }`
### Next steps
For `fail`/blocking checks:
- Include a CTA: `Open required permissions` → links to the tenant-scoped Required Permissions page.
- Optionally include a pre-filtered URL by feature.
## Issues-first rendering
UI should sort checks:
1) blocking fails
2) non-blocking fails
3) warns
4) running
5) skips
6) passes
The onboarding wizard verify step should render the same check set, not only `failure_summary`.

View File

@ -0,0 +1,92 @@
# Data Model — Spec 076 (Permissions Enterprise UI)
## Primary entities
### Tenant
- Source: `app/Models/Tenant.php`
- Used for scoping and tenancy routing (`/admin/t/{tenant}/...`).
### RequiredPermissionDefinition (config)
- Source: `config/intune_permissions.php` (`permissions` array)
- Shape:
- `key: string` (e.g. `DeviceManagementConfiguration.Read.All`)
- `type: 'application'|'delegated'` (current config is application-only, but model supports both)
- `description: ?string`
- `features: string[]` (feature tags used for grouping/impact)
### TenantPermission (DB)
- Source: `app/Models/TenantPermission.php` (table: `tenant_permissions`)
- Key fields (inferred from service usage):
- `tenant_id: int`
- `permission_key: string`
- `status: 'granted'|'missing'|'error'`
- `details: ?array`
- `last_checked_at: ?datetime`
### PermissionComparisonResult (computed)
- Source: `TenantPermissionService::compare(...)`
- Shape:
- `overall_status: 'granted'|'missing'|'error'` (service-level)
- `permissions: PermissionRow[]`
### PermissionRow (computed)
- Shape:
- `key: string`
- `type: 'application'|'delegated'`
- `description: ?string`
- `features: string[]`
- `status: 'granted'|'missing'|'error'`
- `details: ?array`
## View models
### RequiredPermissionsOverview
- Inputs: `PermissionRow[]`
- Derived fields:
- `overall: VerificationReportOverall` where:
- Blocked if any missing application
- NeedsAttention if only delegated missing
- Ready if none missing
- counts:
- `missing_application_count`
- `missing_delegated_count`
- `present_count`
- `error_count`
- `feature_impacts: FeatureImpact[]`
### FeatureImpact
- Key: `feature: string`
- Derived:
- `missing_count`
- `required_application_count`
- `required_delegated_count`
- `blocked: bool` (based on missing application for that feature)
### RequiredPermissionsFilterState
- Livewire-backed state on the page:
- `status: missing|present|all` (default: missing)
- `type: application|delegated|all` (default: all)
- `features: string[]` (default: [])
- `search: string` (default: '')
### CopyPayload
- Derived string payload:
- Always `status = missing`
- Always `type = application|delegated` (fixed by clicked button)
- Respects only `features[]` filter
- Ignores `search`
- Newline separated `permission.key`
## Verification report model (clustered checks)
### VerificationReport (stored on OperationRun)
- Source: `operation_runs.context['verification_report']`
- Schema: `app/Support/Verification/VerificationReportSchema.php`
### VerificationCheck (cluster)
- Key fields (schema-required):
- `key`, `title`, `status`, `severity`, `blocking`, `reason_code`, `message`, `evidence[]`, `next_steps[]`
### Cluster mapping
- Cluster definitions map check key → permission keys (or permission feature sets).
- Permission-derived checks compute status from `PermissionRow[]` and supply next-step URL to the Required Permissions page.

View File

@ -0,0 +1,147 @@
# Implementation Plan: 076-permissions-enterprise-ui
**Branch**: `076-permissions-enterprise-ui` | **Date**: 2026-02-05
**Spec**: specs/076-permissions-enterprise-ui/spec.md
**Input**: specs/076-permissions-enterprise-ui/spec.md
## Summary
Implement the “Tenant Required Permissions (Enterprise Remediation UX)” plus the Verify-step clustering:
- Tenant-scoped Filament Page for required permissions with an overview section + details matrix.
- Copy-to-clipboard for missing permissions split by type (application vs delegated), with clarified semantics.
- Verification report updates to emit 57 clustered checks and onboarding Verify step rendering updates (issues-first), deep-linking to the Required Permissions page.
## Technical Context
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4.0+
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4
**Target Platform**: Filament admin panel with tenancy routing (`/admin/t/{tenant}/...`)
**Project Type**: Laravel monolith
**Performance Goals**: DB-only render; in-memory filtering on config-sized datasets (<~200 rows)
**Constraints**:
- Required Permissions page is DB-only at render (no Graph/HTTP).
- Tenant isolation / RBAC-UX:
- non-member tenant access is 404 via existing middleware
- member without `Capabilities::TENANT_VIEW` is 403
- Badge semantics (BADGE-001): use centralized `BadgeDomain` mappings only.
- Copy semantics: respects Feature filter only; ignores Search; always copies Missing only; Type fixed by clicked button.
- Enterprise correctness: verification runs refresh Observed permissions inventory (Graph) and persist it; viewer surfaces remain DB-only.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Read/write separation: PASS (page is read-only; copy is client-side)
- Graph contract path: PASS (no Graph calls on render)
- RBAC-UX: PASS (404 for non-members; 403 for missing capability)
- Badge semantics: PASS (explicit badge domains are fixed in spec)
## Project Structure
### Documentation (this feature)
- specs/076-permissions-enterprise-ui/spec.md
- specs/076-permissions-enterprise-ui/plan.md (this file)
- specs/076-permissions-enterprise-ui/research.md
- specs/076-permissions-enterprise-ui/data-model.md
- specs/076-permissions-enterprise-ui/contracts/*
- specs/076-permissions-enterprise-ui/quickstart.md
- specs/076-permissions-enterprise-ui/tasks.md (generated next via speckit)
### Code (planned)
- app/Filament/Pages/TenantRequiredPermissions.php (new)
- resources/views/filament/pages/tenant-required-permissions.blade.php (new)
- app/Jobs/ProviderConnectionHealthCheckJob.php (extend verification report)
- app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (render check clusters)
- resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php (render checks)
- tests/Feature/* (Pest)
## Phase 0 — Outline & Research (COMPLETE)
Output: specs/076-permissions-enterprise-ui/research.md
Key decisions:
- Dedicated tenant-scoped Filament Page for Required Permissions.
- Use DB-only permission status (`tenant_permissions`) + `config/intune_permissions.php` for required definitions.
- Implement copy actions via existing robust clipboard fallback pattern.
- Compute clustered verification checks when writing the verification report (job/service), not in Blade.
- Refresh Observed permission inventory during the verification run (Operation Run), not in any viewer surface.
## Phase 1 — Design & Contracts (COMPLETE)
Outputs:
- specs/076-permissions-enterprise-ui/data-model.md
- specs/076-permissions-enterprise-ui/contracts/required-permissions.view-model.json
- specs/076-permissions-enterprise-ui/contracts/verification-report.checks.md
- specs/076-permissions-enterprise-ui/quickstart.md
Remaining required step in this phase:
- Run `.specify/scripts/bash/update-agent-context.sh copilot`.
## Phase 2 — Implementation Planning (READY)
### 2.1 Tenant Required Permissions Page
- Route/tenancy: create tenant page at slug `required-permissions` (under `/admin/t/{tenant}/required-permissions`).
- Authorization:
- non-member 404 is enforced by existing tenancy middleware
- add `canAccess()` check for `Capabilities::TENANT_VIEW` (403)
- Data:
- required definitions: `config/intune_permissions.php`
- tenant status: `tenant_permissions` via `TenantPermissionService` with DB-only semantics
- Overview:
- overall status mapping: Blocked if any missing application; Needs attention if only delegated missing; Ready if none missing
- impacted features summary (from permission → features tags), with clickable cards that apply a Feature filter
- primary next step: Admin consent guide link (prefer existing tenant-specific Admin Consent URL; fall back to external guide)
- Details matrix:
- missing-first sorting
- filters: Feature, Type, Status
- search across key/description
- Badges:
- per-row uses `BadgeDomain::TenantPermissionStatus`
- overview uses `BadgeDomain::VerificationReportOverall`
- Copy actions:
- copy missing application
- copy missing delegated
- selection respects Feature filter only; ignores Search; always Missing-only
### 2.2 Verification Check Clustering
- Extend verification report writing to include 57 clustered checks derived from required permissions status.
- Follow specs/076-permissions-enterprise-ui/contracts/verification-report.checks.md for keys + status rules.
- Ensure permission clusters only assert “missing” when Observed inventory refresh succeeded during the run; otherwise degrade to warnings with retry guidance.
### 2.3 Verify Step UI
- Update onboarding Verify step to render check clusters from `verification_report` (via `VerificationReportViewer`).
- Issues-first ordering: failures, then warnings, then passes.
- Provide an explicit “Open Required Permissions” next-step link.
### 2.4 Tests (Pest)
- Access:
- member without `tenant.view` gets 403
- non-member tenant access remains 404
- Copy semantics:
- Feature filter affects payload; Search does not
- type-specific copy returns only missing of that type
- Verification report:
- cluster keys present
- cluster status mapping matches missing application vs delegated rules

View File

@ -0,0 +1,32 @@
# Quickstart — Spec 076 (Permissions Enterprise UI)
## Local run (Sail)
- Start: `vendor/bin/sail up -d`
- App: `vendor/bin/sail open`
## Where to click
- In the Admin panel (`/admin`), choose a workspace + tenant.
- Open the tenant-scoped “Required permissions” page (`/admin/t/{tenant}/...`).
- In the Managed Tenant Onboarding Wizard → “Verify access”, start verification and review the clustered checks.
## Expected UX
- Overview above the fold:
- Overall status badge (Ready / Needs attention / Blocked)
- Impact summary by feature
- Copy missing application / delegated actions
- Details matrix:
- Missing-first default
- Status/type/feature filters + substring search
## Tests (minimal, targeted)
Run only tests relevant to Spec 076 changes:
- `vendor/bin/sail artisan test --compact --filter=RequiredPermissions`
- If you add a dedicated test file, run it directly:
- `vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissionsTest.php`
## Formatting
- `vendor/bin/sail bin pint --dirty`
## Deploy notes
- No new assets expected (Blade/Livewire only).
- If any Filament assets are registered later, ensure deployment runs `php artisan filament:assets`.

View File

@ -0,0 +1,98 @@
# Research — Spec 076 (Permissions Enterprise UI)
## Decisions
### 1) Build a dedicated tenant-scoped Filament Page
- Decision: Implement a new Filament Page under the tenant panel route (`/admin/t/{tenant}/...`) for the “Required permissions” enterprise remediation UX.
- Rationale:
- The existing `TenantResource` infolist entry is a raw list; Spec 076 requires a two-layer remediation layout (overview + matrix) and feature grouping.
- A dedicated Page can provide operator-first UX without bloating the tenant detail resource.
- Alternatives considered:
- Extending the existing `TenantResource` view: rejected because it couples a complex remediation UI to a general-purpose resource view and makes verification deep-linking/clustering harder.
### 2) Use stored + config-based data only at render time (DB-only render)
- Decision: The page loads required permissions from `config('intune_permissions.permissions')` and granted/missing statuses from the `tenant_permissions` table via `TenantPermissionService::compare($tenant, persist: false, liveCheck: false, useConfiguredStub: false)`.
- Rationale:
- Satisfies FR-076-008 (no external network calls during page view).
- Reuses existing data normalization and status modeling.
- Alternatives considered:
- Calling Graph on page view (`liveCheck: true`): rejected (explicitly out of scope and violates DB-only render).
### 3) Authorization semantics: non-member 404, member missing capability 403
- Decision:
- Non-member tenant access remains deny-as-not-found (404) via the Admin panel middleware (`DenyNonMemberTenantAccess`).
- Page access is capability-gated via `Page::canAccess()` using `Capabilities::TENANT_VIEW`.
- Rationale:
- Matches Constitution RBAC-UX-002 and RBAC-UX-003.
- Ensures correct semantics for both initial request and Livewire requests.
- Alternatives considered:
- Enforcing capability only in `mount()` with custom `abort(...)`: rejected because `canAccess()` is the consistent Filament entry-point gate and keeps nav hiding in sync.
### 4) Badge semantics: use centralized domains only
- Decision:
- Per-permission badges: `BadgeDomain::TenantPermissionStatus`.
- Overall status badge: `BadgeDomain::VerificationReportOverall` with values from `VerificationReportOverall`.
- Rationale:
- Constitution BADGE-001 requires centralized semantic mapping.
### 5) Filters/search implementation: server-side Livewire state, in-memory filtering
- Decision: Represent filter/search state as Livewire properties on the Page and filter the already-loaded permission array in-memory.
- Rationale:
- Dataset size is small (config-defined permissions), so in-memory filtering is fast and stable.
- Enables programmatic tests for filtering/copy payload generation without relying on browser JS.
- Alternatives considered:
- Filament `Tables` with query-backed filters: rejected as unnecessary complexity for a config-driven list.
- Pure client-side Alpine filtering: rejected due to weaker automated testability.
### 6) Copy-to-clipboard: “copy payload modal” + robust clipboard fallback
- Decision: Implement copy actions that open a modal (or inline panel) containing the exact newline-separated payload; a “Copy” button uses the existing robust Alpine clipboard fallback pattern.
- Rationale:
- Clipboard APIs are browser-only; Livewire actions cannot write directly to clipboard.
- Reuses proven fallback approach in `resources/views/filament/partials/json-viewer.blade.php`.
- Makes copy output auditable/visible before copying (enterprise-friendly).
- Alternatives considered:
- Attempting server-side copy: not possible.
### 7) Verify-step clustering: emit clustered checks in `verification_report`
- Decision:
- Extend the queued verification job (`ProviderConnectionHealthCheckJob`) to write a verification report that includes the existing connection check plus 56 permission cluster checks.
- Update the onboarding wizard Verify step to render `OperationRun.context.verification_report` (via `VerificationReportViewer`) and show checks issues-first.
- Rationale:
- The project already has a report schema (`VerificationReportSchema`) and writer (`VerificationReportWriter`).
- Clustering in the report keeps the experience consistent across the wizard and operation-run detail views.
- Alternatives considered:
- Cluster in Blade only: rejected because it does not affect summary/overall and can drift between views.
### 8) Enterprise correctness: refresh Observed permissions during the verification run
- Decision:
- The queued verification run (Operation Run) attempts a live Graph refresh for Observed permissions and persists it to `tenant_permissions`.
- Viewer surfaces (Required Permissions page, onboarding Verify step, operation run viewer) remain DB-only at render time.
- If the refresh fails (429/network), permission clusters degrade to warnings with retry guidance and MUST NOT assert “missing permissions” based on stale/empty inventory.
- Rationale:
- Prevents false “missing” findings when the stored inventory is empty/stale.
- Keeps all external calls in the queued run, maintaining the DB-only render rule.
## Open questions resolved (NEEDS CLARIFICATION → decision)
### “Enabled features” for impact summary
- Decision: In Spec 076 scope, treat “enabled features” as the feature tags present in `config('intune_permissions.permissions')`.
- Rationale: There is no current per-tenant feature-enable registry in the codebase; feature tags already exist and are deterministic.
- Future upgrade path: If/when tenant-specific enablement exists, compute relevance by intersecting enabled features with permission feature tags.
## Check cluster proposal (stable keys)
Target: 57 checks; issues-first.
- `provider.connection.check` (existing)
- `permissions.admin_consent` (overall admin consent / application permissions missing)
- `permissions.directory_groups`
- `permissions.intune_configuration`
- `permissions.intune_apps`
- `permissions.intune_rbac_assignments`
- `permissions.scripts_remediations` (optional / skip when irrelevant)
Each permission-derived check:
- Pass: no missing permissions in its mapped set
- Fail/Blocked: any missing required permission in its set
- Skip: cluster mapped permissions set is empty (or feature not relevant)
- Next step: “Open required permissions” deep link to the new page (optionally pre-filtered by Feature).

View File

@ -0,0 +1,242 @@
# Feature Specification: Tenant Required Permissions Page (Enterprise Remediation UX)
**Feature Branch**: `076-permissions-enterprise-ui`
**Created**: 2026-02-05
**Status**: Draft
**Input**: User description: "Spec 076 — Tenant Required Permissions Page (Enterprise Remediation UX); upgrade tenant required permissions list into an operator-friendly remediation page with summary, prioritization, feature grouping, guidance, copy-to-clipboard, filters/search, strict tenant-scoped RBAC semantics, badge mapping centralization, DB-only render; plus an enterprise check clustering for verification step (57 checks)."
## Clarifications
### Session 2026-02-05
- Q: What capability is required to view the Required permissions page? → A: Require `tenant.view` (Capabilities::TENANT_VIEW). Non-members remain deny-as-not-found.
- Q: What is the overall status mapping for missing permissions? → A: Blocked if any missing application permissions; Needs attention if only delegated permissions are missing; Ready if nothing is missing.
- Q: Should copy-to-clipboard respect filters/search? → A: Copy respects the current Feature filter only; it ignores Search; and it always enforces Status=Missing and Type fixed by the button (app vs delegated).
- Q: Does Spec 076 include Verify-step clustering UI changes? → A: Yes. Spec 076 implements the Required Permissions page and updates the Verify-step UI to show clustered checks (57).
- Q: Which centralized badge mappings should be used? → A: Per-permission status uses `BadgeDomain::TenantPermissionStatus`; Overview overall status uses `BadgeDomain::VerificationReportOverall`.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Operator sees impact at a glance (Priority: P1)
As an Operator, I can immediately understand whether the tenant is blocked, which enabled features are impacted, and how many required permissions are missing.
**Why this priority**: This reduces time-to-diagnosis and prevents “permission soup” confusion during onboarding and incident response.
**Independent Test**: Can be fully tested by loading the page for a tenant with mixed coverage and verifying that the Overview summarizes blocked features and shows “missing-first” by default.
**Acceptance Scenarios**:
1. **Given** a tenant has at least one enabled feature with missing required permissions, **When** I open “Required permissions”, **Then** I see a “Blocked/Needs attention” status and a summary that names impacted features.
2. **Given** a tenant has missing permissions, **When** I first load the page, **Then** the default view shows only missing items and groups them by feature.
---
### User Story 2 - Global Admin can act quickly (Priority: P1)
As a Global Administrator (or delegated privileged operator), I can copy the missing application permissions and missing delegated permissions separately, and I clearly understand that admin consent is required.
**Why this priority**: This turns diagnosis into a one-step remediation action and reduces mistakes (mixing delegated/app permissions).
**Independent Test**: Can be fully tested by verifying that copy actions produce newline-separated permission names for each type and that the guidance block explains “who/how”.
**Acceptance Scenarios**:
1. **Given** there are missing application permissions, **When** I click “Copy missing application permissions”, **Then** my clipboard receives a newline-separated list of the missing application permission names.
2. **Given** there are missing delegated permissions, **When** I click “Copy missing delegated permissions”, **Then** my clipboard receives a newline-separated list of the missing delegated permission names.
3. **Given** any missing permissions exist, **When** I read the guidance section, **Then** it states that a Global Administrator must grant admin consent.
---
### User Story 3 - Deep dive and triage remains possible (Priority: P2)
As an Operator, I can filter and search all required permissions to answer “what exactly is missing, for which feature, and what type is it?”
**Why this priority**: Enables troubleshooting and audit readiness without leaving the product.
**Independent Test**: Can be fully tested by applying filters (Status/Type/Feature) and search terms and verifying table results.
**Acceptance Scenarios**:
1. **Given** a tenant has both present and missing permissions, **When** I change Status to “All”, **Then** I can see both missing and present permissions.
2. **Given** the list contains many permissions, **When** I search by permission key or description, **Then** only matching permissions are shown.
---
### User Story 4 - Unauthorized users see nothing (Priority: P1)
As a non-member of a tenant, I cannot discover the existence of its required permissions page or its contents.
**Why this priority**: Prevents cross-tenant information leakage in enterprise environments.
**Independent Test**: Can be fully tested by requesting the page as (a) a tenant member and (b) a non-member and verifying deny-as-not-found behavior.
**Acceptance Scenarios**:
1. **Given** I am not entitled to the tenant scope, **When** I request the required permissions page, **Then** I receive a not found outcome.
2. **Given** I am entitled to the tenant scope, **When** I request the page, **Then** I can view the summary and permission matrix.
---
### Edge Cases
- What happens when a tenant has zero required permissions (e.g., no enabled features)? Overview MUST show “Ready” and the details list MUST be empty with a clear “nothing required” message.
- What happens when there are no missing permissions? Default “Missing” filter yields an empty state, and Overview MUST show “Ready”.
- How does the system handle a permission that belongs to multiple features? It MUST appear under each relevant feature grouping and contribute to impact counts without double-counting within a feature.
- What happens when copy actions are triggered but there are zero missing permissions of that type? Copy action MUST either be disabled or copy an empty string with an explicit, user-visible message.
- How does the system handle extremely long permission lists? Filtering and search MUST remain usable and stable.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-076-001 — Two-layer layout (Overview + Details)**: The page MUST present two layers:
- **Layer A (Overview, above the fold)**: status banner, impact summary, counts, next-step actions.
- **Layer B (Full matrix)**: full list/matrix with filters + search and grouping options.
- **FR-076-002 — Overview content and defaults**: The Overview MUST include:
1) A status indicator derived from tenant permission coverage (“Ready” / “Needs attention” / “Blocked”).
2) An impact summary that lists which enabled features are blocked or at risk.
3) Counts for missing application permissions, missing delegated permissions, and present permissions.
4) A primary next step that points to an admin consent guide (prefer the existing tenant-specific Admin Consent URL when available; otherwise fall back to an external guide).
5) Secondary actions for copying missing permissions (application vs delegated).
6) A visible “Re-run verification” entry point back to the verification experience.
- **FR-076-002a — Overall status mapping (explicit)**: The overall status badge MUST be computed as:
- **Blocked**: at least one required **application** permission is missing.
- **Needs attention**: no missing application permissions, but at least one required **delegated** permission is missing.
- **Ready**: no missing required permissions.
- **FR-076-003 — Missing-first experience**: The default view MUST show only missing permissions. Users MUST be able to switch to “Present” or “All”.
- **FR-076-004 — Feature-based grouping and impact model**: Each permission is tagged with one or more features. The UI MUST aggregate and present per-feature impact, including:
- missing count per feature
- required application count per feature
- required delegated count per feature
Feature group cards (or equivalent) MUST be clickable to apply a feature filter.
- **FR-076-005 — Full matrix filters and search**: The full matrix MUST support:
- Status filter: Missing / Present / All
- Type filter: Application / Delegated / All
- Feature filter: multi-select across known features
- Search: substring match by permission key and description
- **FR-076-006 — Copy-to-clipboard formats**: The UI MUST provide:
- “Copy missing application permissions”
- “Copy missing delegated permissions”
Output MUST be newline-separated permission names. An optional “Advanced” action MAY provide a structured (non-secret) export; it MUST not include secrets, identifiers that increase tenant leakage risk, or any credential material.
- **FR-076-006a — Copy semantics with filters**: Copy outputs MUST:
- Always include only **Missing** permissions.
- Always include only the permission **Type** corresponding to the clicked button (Application vs Delegated).
- Respect the **Feature** filter if one is applied.
- Ignore any free-text **Search** term.
- **FR-076-007 — Operator guidance block**: The page MUST include a static guidance block that answers:
- “Who can fix this?” (Global Administrator / Privileged Role Administrator)
- “How long does it take?” (510 minutes, optional)
- “After granting consent” → a clear “Re-run verification” action
- **FR-076-008 — Data source and isolation**: Viewing the page MUST use already-available, stored tenant permission requirement data (no external network calls during page view) and MUST be tenant-scoped.
- **FR-076-009 — RBAC semantics (deny-as-not-found)**: Authorization MUST enforce tenant isolation and avoid leakage:
- Viewing the page MUST require the `tenant.view` capability.
- Non-member / not entitled to the tenant scope MUST receive a not found outcome.
- Member without `tenant.view` MUST receive a forbidden outcome.
- No global search or cross-tenant navigation entry points may reveal inaccessible tenants or permission contents.
- **FR-076-010 — Badge semantics centralized (BADGE-001)**: Status-like badges used by this feature (e.g., permission status Missing/Present and overall coverage Ready/Needs attention/Blocked) MUST use a centralized semantic mapping/registry. No ad-hoc badge mapping is allowed inside feature UI logic.
- **FR-076-010a — Badge domains (explicit)**: The UI MUST use these centralized badge domains:
- Per-permission status badge (Missing/Granted/Error): `BadgeDomain::TenantPermissionStatus`
- Overview overall status badge (Ready/Needs attention/Blocked/Running): `BadgeDomain::VerificationReportOverall`
- **FR-076-011 — Verification check clustering (enterprise UX)**: The verification experience MUST support presenting a reduced set of “checks” (57) that cluster individual permissions into operator-friendly topics, while the Required Permissions page remains the deep-dive reference.
- Each check MUST declare which permissions it covers.
- Each check MUST compute a deterministic status based on missing vs present permissions relevant to enabled features.
- Each blocked check MUST provide a next step that routes to the Required Permissions page.
- **FR-076-011a — Verify-step clustered presentation (in scope)**: The Verify-step UI MUST present clustered checks (target 57) instead of listing every permission individually, and MUST be issues-first:
- Blocked checks are shown prominently by default.
- Each blocked check includes a clear CTA to open the Required Permissions page.
- The Verify-step retains a way to view passed/ready checks without overwhelming the default view.
- **FR-076-012 — Recommended default checks and mapping**: The product SHOULD use the following check clusters (names can be user-facing, keys are stable identifiers):
- **C1 Provider authentication works**: can the provider authenticate.
- **C2 Admin consent granted**: can required admin consent be verified.
- **C3 Directory & group read access**: directory + groups prerequisites.
- **C4 Intune configuration access**: configuration + service config permissions.
- **C5 Intune apps access**: apps permissions.
- **C6 Intune RBAC & assignments prerequisites**: RBAC permissions.
- **C7 Scripts/remediations access** (optional): scripts permissions only when relevant features are enabled.
- **FR-076-013 — Cluster status rules (high level)**: For each cluster check:
- **Pass** when all required permissions for enabled features in that cluster are present.
- **Blocked** when any required permission is missing that prevents the related enabled features from functioning.
- **Warning** when only optional/non-blocking permissions are missing.
- **Skipped** when the cluster is irrelevant because the related feature set is not enabled.
- **FR-076-014 — Desired vs Observed + refresh semantics (enterprise correctness)**:
- **Desired** permissions MUST come from configuration (`config/intune_permissions.php`).
- **Observed** permissions MUST come from stored inventory (`tenant_permissions`).
- The verification run (Operation Run) MUST attempt to refresh Observed inventory from Graph and persist it.
- Viewer surfaces (onboarding Verify step, operation run viewer, Required Permissions page) MUST remain DB-only at render time.
- A permission cluster MAY only claim “Missing required permission(s)” when Observed inventory is known-fresh (refreshed successfully during the run).
- If the Observed refresh fails (e.g. throttling/network), permission clusters MUST degrade to **Warning** (non-blocking) with retry guidance; they MUST NOT assert “Missing” based on stale/empty inventory.
- Evidence recorded in the verification report MUST remain sanitized and pointer-only (no raw Graph payloads or secrets).
### Assumptions
- The system already maintains a tenant-scoped dataset of required permissions with attributes: permission name, permission type (application/delegated), status (missing/present), and associated features.
- The system already knows which features are enabled for a tenant.
- The Required Permissions page is reachable from the verification experience ("Open required permissions") and provides a "Re-run verification" path back.
### Dependencies
- Existing tenant required permissions dataset and coverage summary.
- Existing verification experience entry points / deep links (including Spec 075 consumer links).
### Out of Scope
- Changing which permissions are required for a feature.
- Granting admin consent inside the product (this feature only guides and prepares the operator/admin).
- Any external network verification calls during page view.
- Any external network verification calls during Required Permissions page view.
### Security & Evidence
- Verification report evidence MUST be safe-by-default:
- pointer-only (IDs, permission keys, feature tags, HTTP status codes)
- sanitized (no tokens/secrets)
- no raw Graph responses or headers
### Validation Notes (what to verify)
- Overview: status + impacted features are visible without scrolling.
- Overview status: Blocked/Needs attention/Ready matches missing application vs delegated logic.
- Defaults: initial view is missing-only and grouped by feature.
- Copy: application and delegated missing lists copy separately and match the current filtered tenant state.
- Copy + filters: feature-filtered copy produces a feature-scoped missing list; search term does not affect copy.
- Filters/search: Status, Type, Feature multi-select, and search all narrow results predictably.
- RBAC: non-members receive a not found outcome; tenant-scoped users can view.
- Badge semantics: all status badges use a centralized mapping.
- Verification step does not produce false “missing permission” findings when inventory refresh fails; it warns and suggests retry.
### Key Entities *(include if feature involves data)*
- **Tenant**: A customer environment, with membership/entitlement boundaries.
- **Feature**: A product capability (e.g., backup, restore, drift, policy sync) that depends on permissions.
- **Required Permission**: A named permission requirement with attributes: name, type (application/delegated), status (missing/present), and features[].
- **Permission Coverage Summary**: Precomputed or derivable summary that supports overall status (“Ready/Needs attention/Blocked”) plus counts.
- **Verification Check Cluster**: A named check that groups permissions and reports a status + next-step guidance.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- SC-076-001..003 are product/usability metrics and are not fully enforceable in automated tests. This feature uses proxy assertions (e.g., missing-only default, copy semantics, RBAC negative tests, and ≤ 7 clustered checks) to guard the intended experience.
- **SC-076-001**: In usability testing, operators can identify which features are blocked and why in under 15 seconds on first page load.
- **SC-076-002**: Global admins can copy the missing permission list (application or delegated) with one explicit action, with a task completion rate of at least 95%.
- **SC-076-003**: The default “Missing” view reduces initial on-screen items compared to “All”, and users can reliably narrow results using Status/Type/Feature filters and search with a first-try success rate of at least 90%.
- **SC-076-004**: Unauthorized users (non-members) cannot infer tenant existence or permission requirements via the page or global search entry points (validated by negative access tests).
- **SC-076-005**: Verification step presents no more than 7 permission checks for a tenant, while still reflecting all underlying required permissions.

View File

@ -0,0 +1,220 @@
---
description: "Task list for feature implementation"
---
# Tasks: 076-permissions-enterprise-ui
**Input**: Design documents from `specs/076-permissions-enterprise-ui/`
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
**RBAC (required)**:
- Non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- Member but missing capability → 403
- Capabilities MUST come from `App\Support\Auth\Capabilities`
**Badges (required)**:
- Per-permission: `BadgeDomain::TenantPermissionStatus`
- Overview overall: `BadgeDomain::VerificationReportOverall`
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure the repo is ready for implementation and tests.
- [x] T001 Validate local dev quickstart in specs/076-permissions-enterprise-ui/quickstart.md
- [x] T002 Confirm required permission definitions and feature tags exist in config/intune_permissions.php
- [x] T003 [P] Locate and document the clipboard fallback partial to reuse in resources/views/filament/partials/json-viewer.blade.php
- [x] T004 [P] Locate the verification report viewer/rendering surfaces in app/Filament/Support/VerificationReportViewer.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared building blocks used by all user stories.
- [x] T005 Create view-model builder skeleton in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T006 [P] Add unit tests for overall status mapping in tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
- [x] T007 [P] Add unit tests for copy payload semantics in tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
- [x] T008 Add a small DTO/array-shape contract for permission rows in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T009 [P] Add unit tests for per-feature impact aggregation in tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
- [x] T010 Add a helper for Required Permissions deep links in app/Support/Links/RequiredPermissionsLinks.php
**Checkpoint**: Foundation ready (builder + core mapping tests).
---
## Phase 3: User Story 1 — Operator sees impact at a glance (Priority: P1) 🎯 MVP
**Goal**: A tenant-scoped Required Permissions page that clearly shows overall status, impacted features, and missing-first by default.
**Independent Test**: Visit `/admin/t/{tenant}/required-permissions` for a tenant with mixed coverage; verify overview status + impacted features + missing-first list.
- [x] T011 [US1] Create tenant Filament page class in app/Filament/Pages/TenantRequiredPermissions.php
- [x] T012 [US1] Create Blade view in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T013 [US1] Implement `canAccess()` (403 for members without capability) in app/Filament/Pages/TenantRequiredPermissions.php
- [x] T014 [US1] Wire builder into page mount/render using app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T015 [US1] Implement overall Ready/Needs attention/Blocked mapping using BadgeDomain::VerificationReportOverall in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T016 [US1] Render impacted-features summary cards (from permission feature tags) in resources/views/filament/pages/tenant-required-permissions.blade.php; cards are clickable to apply a Feature filter
- [x] T017 [US1] Render missing-first, missing-only default list in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T018 [US1] Render per-permission rows with centralized badge semantics (BadgeDomain::TenantPermissionStatus) in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T019 [P] [US1] Add feature test for page renders overview, missing-first, and feature cards include a click-to-filter wiring in tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php
### Verify-step clustering (in-scope per FR-076-011/011a)
- [x] T020 [US1] Define clustered check keys + grouping logic in app/Support/Verification/TenantPermissionCheckClusters.php
- [x] T021 [US1] Extend verification report writing to include clustered checks in app/Jobs/ProviderConnectionHealthCheckJob.php
- [x] T022 [US1] Ensure clustered checks include next-step URL to Required Permissions (use app/Support/Links/RequiredPermissionsLinks.php)
- [x] T023 [US1] Update onboarding wizard verify step to pass `verification_report` to view in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [x] T024 [US1] Render clustered checks issues-first in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [x] T025 [P] [US1] Add feature test that renders clustered checks in onboarding verify report in tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
- [x] T026 [P] [US1] Add unit tests for cluster status rules in tests/Unit/TenantPermissionCheckClustersTest.php
---
## Phase 4: User Story 2 — Global Admin can act quickly (Priority: P1)
**Goal**: Copy missing application vs delegated permissions separately, with clear guidance about admin consent.
**Independent Test**: From the Required Permissions page, click each copy action and verify output is newline-separated and respects Feature filter only.
- [x] T027 [US2] Add guidance block (“Who can fix this?” / “After granting consent”), including a primary next step link to an admin consent guide (prefer tenant Admin Consent URL; fall back to external guide) in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T028 [US2] Add “Re-run verification” entry point in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T029 [US2] Add “Copy missing application permissions” button + modal in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T030 [US2] Add “Copy missing delegated permissions” button + modal in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T031 [US2] Reuse clipboard fallback logic from resources/views/filament/partials/json-viewer.blade.php in the new copy modal
- [x] T032 [US2] Implement empty-copy UX (disabled action or explicit message) in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T033 [P] [US2] Add unit tests for copy respects Feature filter but ignores Search in tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
- [x] T034 [P] [US2] Add feature test for presence of copy actions + guidance (including admin consent guide link) in tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php
---
## Phase 5: User Story 4 — Unauthorized users see nothing (Priority: P1)
**Goal**: Enforce deny-as-not-found for non-members and forbidden for members lacking `tenant.view`.
**Independent Test**: Request the page as a non-member (404), then as a member without capability (403).
- [x] T035 [US4] Ensure page does not register navigation by default and is not exposed via tenant-agnostic surfaces (e.g., global search / non-tenant nav) in app/Filament/Pages/TenantRequiredPermissions.php
- [x] T036 [P] [US4] Add feature test: non-member tenant access is 404 in tests/Feature/RequiredPermissions/RequiredPermissionsRbacTest.php
- [x] T037 [P] [US4] Add feature test: member without tenant.view gets 403 in tests/Feature/RequiredPermissions/RequiredPermissionsRbacTest.php
- [x] T038 [US4] Ensure capability checks reference registry constants (no raw strings) in app/Filament/Pages/TenantRequiredPermissions.php
- [x] T039 [US4] Ensure any deep links used by verification report do not leak cross-tenant data in app/Support/Links/RequiredPermissionsLinks.php
- [x] T040 [P] [US4] Add regression test for link generation staying tenant-scoped in tests/Unit/RequiredPermissionsLinksTest.php
---
## Phase 6: User Story 3 — Deep dive and triage remains possible (Priority: P2)
**Goal**: Filter/search the full matrix by Status/Type/Feature and search by permission key/description.
**Independent Test**: Apply filters and search; verify results update predictably and missing-first remains stable.
- [x] T041 [US3] Add Status filter (Missing/Present/All) state handling in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T042 [US3] Add Type filter (Application/Delegated/All) state handling in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T043 [US3] Add Feature multi-select filter support in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [x] T044 [US3] Add substring search (by permission key/description) applied at render time (not affecting copy) in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T045 [US3] Add UI controls for filters/search in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T046 [P] [US3] Add unit tests for filter/search behavior in tests/Unit/TenantRequiredPermissionsFilteringTest.php
- [x] T047 [P] [US3] Add feature test for filters narrowing results in tests/Feature/RequiredPermissions/RequiredPermissionsFiltersTest.php
- [x] T048 [US3] Ensure copy payload ignores Search but respects Feature filter (assert in builder) in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
---
## Phase 7: Polish & Cross-Cutting Concerns
- [x] T049 Run Pint formatting for touched files via vendor/bin/sail bin pint (see specs/076-permissions-enterprise-ui/quickstart.md)
- [x] T050 Run targeted Pest tests via vendor/bin/sail artisan test --compact (see specs/076-permissions-enterprise-ui/quickstart.md)
- [x] T051 [P] Ensure table empty states are meaningful (zero required / zero missing) in resources/views/filament/pages/tenant-required-permissions.blade.php
- [x] T052 [P] Ensure the Verify-step check list does not exceed 7 items and remains issues-first in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [x] T053 [P] Add regression feature test: Required Permissions page render remains DB-only (no Graph client calls) in tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
---
## Phase 8: Enterprise Correctness — Observed Refresh in Verification Run
**Goal**: Prevent false “missing permissions” findings by refreshing Observed permissions inventory during the queued verification run (Operation Run), while keeping all viewer surfaces DB-only.
- [x] T054 Update live-check failure semantics in app/Services/Intune/TenantPermissionService.php (do not overwrite stored inventory; return live-check metadata)
- [x] T055 Refresh observed permissions in app/Jobs/ProviderConnectionHealthCheckJob.php during successful provider checks (`liveCheck=true`, `persist=true`) and pass inventory freshness context into clustered checks
- [x] T055a Use ProviderConnection graph options for permission refresh (avoid falling back to Tenant/global Graph config)
- [x] T056 Degrade permission clusters to warnings when inventory refresh fails in app/Support/Verification/TenantPermissionCheckClusters.php
- [x] T057 Tighten verification report evidence safety via allowlisting in app/Support/Verification/VerificationReportSanitizer.php
- [x] T058 Add/adjust Pest tests covering: permission refresh invoked on healthy run, throttling/network refresh failure becomes warning (not missing), and no Graph calls are introduced into viewer renders
- [x] T059 Treat successful-but-unmappable Graph permission inventory as non-fresh (warn) and add regression coverage (reason_code: permission_mapping_failed)
- [x] T060 Degrade to warnings when live refresh returns empty inventory; surface app_id + observed count in verification report evidence
---
## Phase 9: Onboarding Wizard — Inline “Edit selected connection” (Option 1)
**Goal**: Edit the selected Provider Connection inline inside the onboarding wizard (SlideOver/Modal), without tenant-context navigation, while enforcing capability-first RBAC and requiring an explicit verification re-run after edits.
- [x] T061 Replace tenant-scoped edit link with an inline SlideOver edit action in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [x] T062 Enforce RBAC: action disabled without capability, server-side 403 for missing capability, 404 for non-member/other-tenant scope
- [x] T063 After save: invalidate verification/bootstrap state and set a “connection updated” flag so Verify step shows “Re-run verification” guidance
- [x] T064 Add audit event `provider_connection.updated` with redacted metadata (no secrets)
- [x] T065 Add Pest feature tests covering RBAC, wizard continuity, no tenant-context dependency/links, secret safety, and audit entry
- [x] T066 Run Pint + targeted Pest tests for the new behavior
---
## Phase 10: Onboarding Wizard — Verify “Technical details” SlideOver
**Goal**: In the Verify step, provide a "Technical details" SlideOver with compact Operation Run summary and a "Refresh results" action, without showing an empty "Report unavailable" card in the SlideOver.
- [x] T067 Add Verify-step "Technical details" SlideOver showing run summary (run id/status/outcome, started/updated/completed, operation type + Entra tenant scope) and optional "Open full page" link
- [x] T068 Add/adjust Pest feature test to ensure the Verify step renders the "Technical details" affordance when a verification run exists
---
## Dependencies & Execution Order
### User Story completion order
- Setup → Foundational → US1 → (US2, US4 in parallel) → US3 → Polish
### Dependency graph
- US1 depends on Foundational (view-model builder + mappings)
- US2 depends on US1 (copy actions live on the page)
- US4 depends on US1 (route exists to assert 404/403)
- US3 depends on US1 (matrix exists to filter)
## Parallel execution examples
### US1
- In parallel:
- T011 (Page class) and T012 (Blade view)
- T019 (feature test file scaffolding) can start once route is known
### US2
- In parallel:
- T029/T030 (two copy buttons/modals) can be developed independently
- T033 unit tests can be written while UI is built
### US4
- In parallel:
- T036/T037 RBAC tests can be authored alongside US1 once page route exists
### US3
- In parallel:
- T041T043 builder filter support can be built while T045 UI controls are built
- T046 unit tests can be written alongside implementation
## Implementation Strategy
### MVP scope (recommended)
- Complete Phase 1 + Phase 2 + Phase 3 (US1) first.
- Validate independently via tests and by loading the tenant page.
### Incremental delivery
- Add copy + guidance (US2), then RBAC regression coverage (US4), then filters/search (US3).

View File

@ -0,0 +1,26 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps onboarding verification status blocked to a Blocked danger badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'blocked');
expect($spec->label)->toBe('Blocked');
expect($spec->color)->toBe('danger');
expect($spec->icon)->toBe('heroicon-m-x-circle');
});
it('maps onboarding verification status ready to a Ready success badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'ready');
expect($spec->label)->toBe('Ready');
expect($spec->color)->toBe('success');
expect($spec->icon)->toBe('heroicon-m-check-circle');
});
it('normalizes onboarding verification status input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'NEEDS ATTENTION');
expect($spec->label)->toBe('Needs attention');
});

View File

@ -0,0 +1,47 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps verification check status fail to a Fail danger badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
expect($spec->label)->toBe('Fail');
expect($spec->color)->toBe('danger');
expect($spec->icon)->toBe('heroicon-m-x-circle');
});
it('normalizes verification check status input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'RUNNING');
expect($spec->label)->toBe('Running');
});
it('maps verification check severity critical to a Critical danger badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
expect($spec->label)->toBe('Critical');
expect($spec->color)->toBe('danger');
expect($spec->icon)->toBe('heroicon-m-x-circle');
});
it('maps empty verification check severity to an Unknown badge (v1.5 allows empty severity)', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, '');
expect($spec->label)->toBe('Unknown');
});
it('maps verification report overall needs_attention to a Needs attention warning badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
expect($spec->label)->toBe('Needs attention');
expect($spec->color)->toBe('warning');
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
});
it('normalizes verification report overall input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'NEEDS ATTENTION');
expect($spec->label)->toBe('Needs attention');
});

View File

@ -13,7 +13,7 @@
uses(RefreshDatabase::class);
it('redirects /admin to the workspace managed-tenants landing when a workspace is selected and has no tenants', function (): void {
it('redirects /admin to onboarding when a workspace is selected and has no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
@ -28,7 +28,7 @@
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin')
->assertRedirect(route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]));
->assertRedirect('/admin/onboarding');
});
it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void {

View File

@ -29,7 +29,7 @@
'ownerRecord' => $tenant,
'pageClass' => ViewTenant::class,
])
->assertSee($member->name);
->assertSee($member->email);
Bus::assertNothingDispatched();
});

View File

@ -2,89 +2,86 @@
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\TenantMembershipManager;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Gate;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('returns 404 for non-members when starting onboarding', function (): void {
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->get('/admin/onboarding')
->assertNotFound();
});
it('returns 403 for workspace members without onboarding capability', function (): void {
it('allows workspace members without onboarding capability to view the wizard but forbids execution', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'readonly',
]);
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->assertForbidden();
});
it('renders onboarding wizard for workspace owners', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get("/admin/w/{$workspace->getKey()}/managed-tenants/onboarding")
->get('/admin/onboarding')
->assertSuccessful();
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'environment' => 'prod',
'name' => 'Acme',
])
->assertStatus(403);
expect(Tenant::query()->count())->toBe(0);
expect(TenantOnboardingSession::query()->count())->toBe(0);
});
it('allows owners to identify a managed tenant and creates a pending tenant + session', function (): void {
it('renders onboarding wizard for workspace owners and allows identifying a managed tenant', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful();
$tenantGuid = '11111111-1111-1111-1111-111111111111';
$entraTenantId = '22222222-2222-2222-2222-222222222222';
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Initial onboarding',
]);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey());
expect($tenant->status)->toBe('pending');
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'current_step' => 'identify',
]);
expect($tenant->status)->toBe(Tenant::STATUS_ONBOARDING);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => (int) $tenant->getKey(),
@ -92,134 +89,86 @@
'role' => 'owner',
]);
expect(
(int) \App\Models\TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('role', 'owner')
->count()
)->toBe(1);
$this->assertDatabaseHas('managed_tenant_onboarding_sessions', [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'identify',
]);
});
it('upgrades the initiating user to owner if they already have a lower tenant role', function (): void {
it('is idempotent when identifying the same Entra tenant ID twice', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenantGuid = '66666666-6666-6666-6666-666666666666';
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => $tenantGuid,
$entraTenantId = '33333333-3333-3333-3333-333333333333';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
'status' => 'pending',
]);
\App\Models\TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
'source' => 'manual',
'created_by_user_id' => (int) $user->getKey(),
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
]);
$this->actingAs($user);
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$membership = \App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $user->getKey())
->firstOrFail();
expect($membership->role)->toBe('owner');
expect(\App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $user->getKey())
expect(Tenant::query()->where('tenant_id', $entraTenantId)->count())->toBe(1);
expect(TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', $entraTenantId)
->whereNull('completed_at')
->count())->toBe(1);
});
it('writes audit logs for onboarding start and resume', function (): void {
$workspace = Workspace::factory()->create();
it('returns 404 and does not create anything when entra_tenant_id exists in another workspace', function (): void {
$entraTenantId = '44444444-4444-4444-4444-444444444444';
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '44444444-4444-4444-4444-444444444444';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.start',
'resource_type' => 'tenant',
'resource_id' => (string) $tenant->getKey(),
'status' => 'success',
]);
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => (int) $user->getKey(),
'action' => 'managed_tenant_onboarding.resume',
'resource_type' => 'tenant',
'resource_id' => (string) $tenant->getKey(),
'status' => 'success',
]);
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('resource_type', 'tenant')
->where('resource_id', (string) $tenant->getKey())
->whereIn('action', ['managed_tenant_onboarding.start', 'managed_tenant_onboarding.resume'])
->count())->toBeGreaterThanOrEqual(2);
});
it('blocks demoting or removing the last remaining tenant owner', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ACTIVE,
]);
$tenantGuid = '55555555-5555-5555-5555-555555555555';
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace])
->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$membership = \App\Models\TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $user->getKey())
->firstOrFail();
expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $user, $membership, 'manager'))
->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
expect(fn () => app(TenantMembershipManager::class)->removeMember($tenant, $user, $membership))
->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Other Workspace',
])
->assertStatus(404);
});
it('returns 404 for legacy onboarding entry points', function (): void {
@ -232,15 +181,19 @@
$this->get('/admin/new')->assertNotFound();
});
it('is idempotent when identifying the same managed tenant twice', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
/*
|--------------------------------------------------------------------------
| Legacy onboarding suite (deprecated)
|--------------------------------------------------------------------------
|
| The remainder of this file previously contained an end-to-end onboarding
| suite that relied on deprecated routes and pre-enterprise state semantics.
| Spec 073 replaces it with focused coverage under tests/Feature/Onboarding
| and tests/Feature/Rbac.
|
| Keeping the legacy assertions around (commented) is intentional to avoid
| reintroducing removed routes or old semantics.
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
@ -872,3 +825,5 @@
'current_step' => 'identify',
]);
});
*/

View File

@ -63,6 +63,33 @@
expect($user->notifications()->count())->toBe(0);
});
it('uses a tenantless view link for managed tenant onboarding wizard runs', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'verification',
],
],
]);
$user->notify(new OperationRunQueued($run));
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::tenantlessView($run));
});
it('emits a terminal notification when an operation run transitions to completed', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('denies activation to non-owners even when verification succeeded', function (): void {
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '88888888-8888-8888-8888-888888888888';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
]);
$component->call('createProviderConnection', [
'display_name' => 'Acme connection',
'client_id' => '00000000-0000-0000-0000-000000000000',
'client_secret' => 'super-secret',
'is_default' => true,
]);
$component->call('startVerification');
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->firstOrFail();
$run->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$component
->call('completeOnboarding')
->assertStatus(403);
$tenant->refresh();
expect($tenant->status)->not->toBe(Tenant::STATUS_ACTIVE);
});
it('requires an override reason when verification is blocked and records an audit event when overridden', function (): void {
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '99999999-9999-9999-9999-999999999999';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
]);
$component->call('createProviderConnection', [
'display_name' => 'Acme connection',
'client_id' => '00000000-0000-0000-0000-000000000000',
'client_secret' => 'super-secret',
'is_default' => true,
]);
$component->call('startVerification');
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->firstOrFail();
$run->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$component
->set('data.override_blocked', true)
->set('data.override_reason', '')
->call('completeOnboarding')
->assertHasErrors(['data.override_reason']);
$component
->set('data.override_blocked', true)
->set('data.override_reason', 'Temporary exception approved by owner')
->call('completeOnboarding');
$tenant->refresh();
expect($tenant->status)->toBe(Tenant::STATUS_ACTIVE);
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('redirects to choose-workspace when visiting /admin/onboarding without a selected workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get('/admin/onboarding')
->assertRedirect('/admin/choose-workspace');
});
it('renders the onboarding wizard at /admin/onboarding when a workspace is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful();
});

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
it('registers managed tenant onboarding wizard capabilities in the canonical registry', function (): void {
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP))->toBeTrue();
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))->toBeTrue();
});
it('maps onboarding wizard capabilities to workspace roles (least privilege)', function (): void {
expect(WorkspaceRoleCapabilityMap::hasCapability('owner', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('manager', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('operator', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY))->toBeFalse();
expect(WorkspaceRoleCapabilityMap::hasCapability('readonly', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY))->toBeFalse();
expect(WorkspaceRoleCapabilityMap::hasCapability('owner', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))->toBeTrue();
expect(WorkspaceRoleCapabilityMap::hasCapability('manager', Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))->toBeFalse();
});
it('supports the v1 managed tenant lifecycle statuses', function (): void {
$draft = Tenant::factory()->create(['status' => Tenant::STATUS_DRAFT]);
$onboarding = Tenant::factory()->create(['status' => Tenant::STATUS_ONBOARDING]);
$active = Tenant::factory()->create(['status' => Tenant::STATUS_ACTIVE]);
expect(Tenant::activeQuery()->pluck('id')->all())->toContain((int) $active->getKey());
expect(Tenant::activeQuery()->pluck('id')->all())->not->toContain((int) $draft->getKey());
expect(Tenant::activeQuery()->pluck('id')->all())->not->toContain((int) $onboarding->getKey());
$onboarding->delete();
$onboarding->refresh();
expect($onboarding->status)->toBe(Tenant::STATUS_ARCHIVED);
$onboarding->restore();
$onboarding->refresh();
expect($onboarding->status)->toBe(Tenant::STATUS_ACTIVE);
});

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('is idempotent when identifying the same Entra tenant ID twice in the same workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$entraTenantId = '11111111-1111-1111-1111-111111111111';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Initial onboarding',
]);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Initial onboarding',
]);
expect(Tenant::query()->where('tenant_id', $entraTenantId)->count())->toBe(1);
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
expect((int) $tenant->workspace_id)->toBe((int) $workspace->getKey());
expect(TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', $entraTenantId)
->whereNull('completed_at')
->count())->toBe(1);
});
it('responds with deny-as-not-found when attempting to identify an Entra tenant ID that belongs to another workspace', function (): void {
$entraTenantId = '22222222-2222-2222-2222-222222222222';
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ACTIVE,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Other Workspace',
])
->assertStatus(404);
});

View File

@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('returns 403 when updating a selected connection without manage capability', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '11111111-1111-1111-1111-111111111111',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Acme connection',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => 'top-secret-client-secret',
],
]);
TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
])
->assertStatus(403);
});
it('returns 404 when a non-member attempts inline connection update', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '22222222-2222-2222-2222-222222222222',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Acme connection',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => 'top-secret-client-secret',
],
]);
TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->assertStatus(404);
});
it('updates connection inline, invalidates verification state, and writes audit metadata without secrets', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '33333333-3333-3333-3333-333333333333',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Acme connection',
'is_default' => true,
]);
$secret = 'top-secret-client-secret';
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => $secret,
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'provider.connection.check',
]);
$session = TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'bootstrap_operation_runs' => [123, 456],
'bootstrap_operation_types' => ['inventory.sync'],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
])
->assertSuccessful();
$connection->refresh();
expect($connection->display_name)->toBe('Updated name');
$credential = $connection->credential;
expect($credential)->not->toBeNull();
expect($credential?->payload['client_id'] ?? null)->toBe('new-client-id');
expect($credential?->payload['client_secret'] ?? null)->toBe($secret);
$session->refresh();
expect($session->state['verification_operation_run_id'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_runs'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_types'] ?? null)->toBeNull();
expect($session->state['connection_recently_updated'] ?? null)->toBeTrue();
$audit = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.updated')
->latest('id')
->first();
expect($audit)->not->toBeNull();
$encodedMetadata = json_encode($audit?->metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
expect($encodedMetadata)->not->toContain($secret);
});
it('returns 404 when attempting to inline-edit a connection belonging to a different tenant', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '44444444-4444-4444-4444-444444444444',
'status' => Tenant::STATUS_ONBOARDING,
]);
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '55555555-5555-5555-5555-555555555555',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'display_name' => 'Other tenant connection',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => 'top-secret-client-secret',
],
]);
TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
])
->assertStatus(404);
});

Some files were not shown because too many files have changed in this diff Show More