Compare commits

...

3 Commits

Author SHA1 Message Date
439248ba15 feat: verification report framework (074) (#89)
Implements the 074 verification checklist framework.

Highlights:
- Versioned verification report contract stored in operation_runs.context.verification_report (DB-only viewer).
- Strict sanitizer/redaction (evidence pointers only; no tokens/headers/payloads) + schema validation.
- Centralized BADGE-001 semantics for check status, severity, and overall report outcome.
- Deterministic start (dedupe while active) via shared StartVerification service; capability-first authorization (non-member 404, member missing capability 403).
- Completion audit event (verification.completed) with redacted metadata.
- Integrations: OperationRun detail viewer, onboarding wizard verification step, provider connection start surfaces.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Verification tests/Unit/Badges/VerificationBadgesTest.php
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #89
2026-02-03 23:58:17 +00:00
b6343d5c3a feat: unified managed tenant onboarding wizard (#88)
Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #88
2026-02-03 17:30:15 +00:00
5f9e6fb04a feat: workspace-first managed tenants + RBAC membership UI fixes (072) (#87)
Implements spec 072 (workspace-first managed tenants enforcement) and follow-up RBAC fixes.

Highlights:
- Workspace-scoped managed tenants landing and enforcement for tenant routes.
- Workspace membership management UI fixed to use workspace capabilities.
- Membership tables now show user email + domain for clearer identification.

Tests:
- Targeted Pest tests for routing/enforcement and RBAC UI enforcement.
- Pint ran on dirty files.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #87
2026-02-02 23:54:22 +00:00
127 changed files with 7413 additions and 640 deletions

View File

@ -14,6 +14,8 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting) - PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -33,9 +35,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 - 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 - 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@ -4,7 +4,6 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\UserTenantPreference; use App\Models\UserTenantPreference;
@ -73,11 +72,6 @@ public function selectTenant(int $tenantId): void
$this->redirect(TenantDashboard::getUrl(tenant: $tenant)); $this->redirect(TenantDashboard::getUrl(tenant: $tenant));
} }
public function canRegisterTenant(): bool
{
return RegisterTenantPage::canView();
}
private function persistLastTenant(User $user, Tenant $tenant): void private function persistLastTenant(User $user, Tenant $tenant): void
{ {
if (Schema::hasColumn('users', 'last_tenant_id')) { if (Schema::hasColumn('users', 'last_tenant_id')) {

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages\Workspaces; namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -48,11 +47,6 @@ public function getTenants(): Collection
->get(); ->get();
} }
public function canRegisterTenant(): bool
{
return RegisterTenantPage::canView();
}
public function goToChooseTenant(): void public function goToChooseTenant(): void
{ {
$this->redirect(ChooseTenant::getUrl()); $this->redirect(ChooseTenant::getUrl());

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages; use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
@ -136,12 +137,35 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary)) ->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(), ->columnSpanFull(),
Section::make('Verification report')
->schema([
ViewEntry::make('verification_report')
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
->columnSpanFull(),
Section::make('Context') Section::make('Context')
->schema([ ->schema([
ViewEntry::make('context') ViewEntry::make('context')
->label('') ->label('')
->view('filament.infolists.entries.snapshot-json') ->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->context ?? []) ->state(function (OperationRun $record): array {
$context = $record->context ?? [];
$context = is_array($context) ? $context : [];
if (array_key_exists('verification_report', $context)) {
$context['verification_report'] = [
'redacted' => true,
'note' => 'Rendered in the Verification report section.',
];
}
return $context;
})
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),

View File

@ -5,7 +5,6 @@
use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages; use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob; use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -15,6 +14,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
@ -175,29 +175,22 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant) {
return; abort(404);
} }
$initiator = $user; if (! $user instanceof User) {
abort(403);
}
$result = $gate->start( $result = $verification->providerConnectionCheck(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'provider.connection.check', initiator: $user,
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
); );
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob; use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -14,6 +13,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -167,7 +167,7 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && $record->status !== 'disabled';
}) })
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
$initiator = $user; $initiator = $user;
$result = $gate->start( $result = $verification->providerConnectionCheck(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator, initiator: $initiator,
); );

View File

@ -25,11 +25,22 @@ public function table(Table $table): Table
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([ ->columns([
Tables\Columns\TextColumn::make('user.name') Tables\Columns\TextColumn::make('user.email')
->label(__('User')) ->label(__('User'))
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('user.email') Tables\Columns\TextColumn::make('user_domain')
->label(__('Email')) ->label(__('Domain'))
->getStateUsing(function (TenantMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role') Tables\Columns\TextColumn::make('role')
->badge() ->badge()
@ -49,7 +60,13 @@ public function table(Table $table): Table
->label(__('User')) ->label(__('User'))
->required() ->required()
->searchable() ->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), ->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role') Forms\Components\Select::make('role')
->label(__('Role')) ->label(__('Role'))
->required() ->required()

View File

@ -8,7 +8,7 @@
use App\Services\Auth\WorkspaceMembershipManager; use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole; use App\Support\Auth\WorkspaceRole;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -26,11 +26,22 @@ public function table(Table $table): Table
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([ ->columns([
Tables\Columns\TextColumn::make('user.name') Tables\Columns\TextColumn::make('user.email')
->label(__('User')) ->label(__('User'))
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('user.email') Tables\Columns\TextColumn::make('user_domain')
->label(__('Email')) ->label(__('Domain'))
->getStateUsing(function (WorkspaceMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role') Tables\Columns\TextColumn::make('role')
->badge() ->badge()
@ -38,7 +49,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('created_at')->since(), Tables\Columns\TextColumn::make('created_at')->since(),
]) ])
->headerActions([ ->headerActions([
UiEnforcement::forTableAction( WorkspaceUiEnforcement::forTableAction(
Action::make('add_member') Action::make('add_member')
->label(__('Add member')) ->label(__('Add member'))
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
@ -47,7 +58,13 @@ public function table(Table $table): Table
->label(__('User')) ->label(__('User'))
->required() ->required()
->searchable() ->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), ->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role') Forms\Components\Select::make('role')
->label(__('Role')) ->label(__('Role'))
->required() ->required()
@ -105,7 +122,7 @@ public function table(Table $table): Table
->apply(), ->apply(),
]) ])
->actions([ ->actions([
UiEnforcement::forTableAction( WorkspaceUiEnforcement::forTableAction(
Action::make('change_role') Action::make('change_role')
->label(__('Change role')) ->label(__('Change role'))
->icon('heroicon-o-pencil') ->icon('heroicon-o-pencil')
@ -159,7 +176,7 @@ public function table(Table $table): Table
->tooltip('You do not have permission to manage workspace memberships.') ->tooltip('You do not have permission to manage workspace memberships.')
->apply(), ->apply(),
UiEnforcement::forTableAction( WorkspaceUiEnforcement::forTableAction(
Action::make('remove') Action::make('remove')
->label(__('Remove')) ->label(__('Remove'))
->color('danger') ->color('danger')

View File

@ -17,6 +17,8 @@ class WorkspaceResource extends Resource
{ {
protected static ?string $model = Workspace::class; protected static ?string $model = Workspace::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false; protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordTitleAttribute = 'name';

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
final class VerificationReportViewer
{
/**
* @return array<string, mixed>|null
*/
public static function report(OperationRun $run): ?array
{
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
if (! is_array($report)) {
return null;
}
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
return null;
}
return $report;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];
if (array_key_exists('verification_report', $context)) {
return true;
}
return in_array((string) $run->type, ['provider.connection.check'], true);
}
}

View File

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

View File

@ -7,11 +7,14 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\Contracts\HealthResult; use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck; use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -83,17 +86,64 @@ public function handle(
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName); $this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$report = VerificationReportWriter::write(
run: $this->operationRun,
checks: [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => $result->healthy ? 'pass' : 'fail',
'severity' => $result->healthy ? 'info' : 'critical',
'blocking' => ! $result->healthy,
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
'evidence' => array_values(array_filter([
[
'kind' => 'provider_connection_id',
'value' => (int) $connection->getKey(),
],
[
'kind' => 'entra_tenant_id',
'value' => (string) $connection->entra_tenant_id,
],
is_numeric($result->meta['http_status'] ?? null) ? [
'kind' => 'http_status',
'value' => (int) $result->meta['http_status'],
] : null,
is_string($result->meta['organization_id'] ?? null) ? [
'kind' => 'organization_id',
'value' => (string) $result->meta['organization_id'],
] : null,
])),
'next_steps' => $result->healthy
? []
: [[
'label' => 'Review provider connection',
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
'record' => (int) $connection->getKey(),
], tenant: $tenant),
]],
],
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => (string) $connection->entra_tenant_id,
],
);
if ($result->healthy) { if ($result->healthy) {
$runs->updateRun( $run = $runs->updateRun(
$this->operationRun, $this->operationRun,
status: OperationRunStatus::Completed->value, status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value, outcome: OperationRunOutcome::Succeeded->value,
); );
$this->logVerificationCompletion($tenant, $user, $run, $report);
return; return;
} }
$runs->updateRun( $run = $runs->updateRun(
$this->operationRun, $this->operationRun,
status: OperationRunStatus::Completed->value, status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value, outcome: OperationRunOutcome::Failed->value,
@ -103,6 +153,8 @@ public function handle(
'message' => $result->message ?? 'Health check failed.', 'message' => $result->message ?? 'Health check failed.',
]], ]],
); );
$this->logVerificationCompletion($tenant, $user, $run, $report);
} }
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
@ -145,4 +197,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
'last_error_message' => $result->healthy ? null : $result->message, 'last_error_message' => $result->healthy ? null : $result->message,
]); ]);
} }
/**
* @param array<string, mixed> $report
*/
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
{
$workspace = $tenant->workspace;
if (! $workspace) {
return;
}
$counts = $report['summary']['counts'] ?? [];
$counts = is_array($counts) ? $counts : [];
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::VerificationCompleted->value,
context: [
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
'counts' => $counts,
],
],
actor: $actor,
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
}
} }

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantOnboardingSession extends Model
{
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
use HasFactory;
protected $table = 'managed_tenant_onboarding_sessions';
protected $guarded = [];
protected $casts = [
'state' => 'array',
'completed_at' => 'datetime',
];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function startedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function updatedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by_user_id');
}
}

View File

@ -6,8 +6,10 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Policies\ProviderConnectionPolicy; use App\Policies\ProviderConnectionPolicy;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -23,19 +25,36 @@ public function boot(): void
{ {
$this->registerPolicies(); $this->registerPolicies();
$resolver = app(CapabilityResolver::class); $tenantResolver = app(CapabilityResolver::class);
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void { $defineTenantCapability = function (string $capability) use ($tenantResolver): void {
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool { Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return false; return false;
} }
return $resolver->can($user, $tenant, $capability); return $tenantResolver->can($user, $tenant, $capability);
});
};
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
if (! $workspace instanceof Workspace) {
return false;
}
return $workspaceResolver->can($user, $workspace, $capability);
}); });
}; };
foreach (Capabilities::all() as $capability) { foreach (Capabilities::all() as $capability) {
if (str_starts_with($capability, 'workspace')) {
$defineWorkspaceCapability($capability);
continue;
}
$defineTenantCapability($capability); $defineTenantCapability($capability);
} }

View File

@ -6,8 +6,8 @@
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess; use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -42,25 +42,20 @@ public function panel(Panel $panel): Panel
ChooseWorkspace::registerRoutes($panel); ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel); ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel); NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
}) })
->tenant(Tenant::class, slugAttribute: 'external_id') ->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t') ->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant())) ->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu() ->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make('Workspaces') NavigationItem::make('Workspaces')
->url(function (): string { ->url(function (): string {
$tenant = Filament::getTenant(); return route('filament.admin.resources.workspaces.index');
if ($tenant instanceof Tenant) {
return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]);
}
return ChooseWorkspace::getUrl();
}) })
->icon('heroicon-o-squares-2x2') ->icon('heroicon-o-squares-2x2')
->group('Settings') ->group('Settings')

View File

@ -7,6 +7,7 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class WorkspaceAuditLogger class WorkspaceAuditLogger
@ -26,6 +27,10 @@ public function log(
$metadata = $context['metadata'] ?? []; $metadata = $context['metadata'] ?? [];
unset($context['metadata']); unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([ return AuditLog::create([
'tenant_id' => null, 'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
@ -36,7 +41,7 @@ public function log(
'resource_type' => $resourceType, 'resource_type' => $resourceType,
'resource_id' => $resourceId, 'resource_id' => $resourceId,
'status' => $status, 'status' => $status,
'metadata' => $metadata + $context, 'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(), 'recorded_at' => CarbonImmutable::now(),
]); ]);
} }

View File

@ -23,12 +23,14 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_ARCHIVE, Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW, Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
], ],
WorkspaceRole::Manager->value => [ WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW, Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW, Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
], ],
WorkspaceRole::Operator->value => [ WorkspaceRole::Operator->value => [

View File

@ -4,6 +4,7 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class AuditLogger class AuditLogger
@ -22,6 +23,10 @@ public function log(
$metadata = $context['metadata'] ?? []; $metadata = $context['metadata'] ?? [];
unset($context['metadata']); unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([ return AuditLog::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'actor_id' => $actorId, 'actor_id' => $actorId,
@ -31,7 +36,7 @@ public function log(
'resource_type' => $resourceType, 'resource_type' => $resourceType,
'resource_id' => $resourceId, 'resource_id' => $resourceId,
'status' => $status, 'status' => $status,
'metadata' => $metadata + $context, 'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(), 'recorded_at' => CarbonImmutable::now(),
]); ]);
} }

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\Verification;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class StartVerification
{
public function __construct(
private readonly ProviderOperationStartGate $providers,
) {}
/**
* Start (or dedupe) a provider-connection verification run.
*
* @param array<string, mixed> $extraContext
*/
public function providerConnectionCheck(
Tenant $tenant,
ProviderConnection $connection,
User $initiator,
array $extraContext = [],
): ProviderOperationStartResult {
if (! $initiator->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
return $this->providers->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
},
initiator: $initiator,
extraContext: $extraContext,
);
}
}

View File

@ -22,4 +22,11 @@ enum AuditActionId: string
// Diagnostics / repair actions. // Diagnostics / repair actions.
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged'; case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
// Managed tenant onboarding wizard.
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
case VerificationCompleted = 'verification.completed';
} }

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
final class AuditContextSanitizer
{
private const REDACTED = '[REDACTED]';
public static function sanitize(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $key => $item) {
if (is_string($key) && self::shouldRedactKey($key)) {
$sanitized[$key] = self::REDACTED;
continue;
}
$sanitized[$key] = self::sanitize($item);
}
return $sanitized;
}
if (is_string($value)) {
return self::sanitizeString($value);
}
return $value;
}
private static function shouldRedactKey(string $key): bool
{
$key = strtolower(trim($key));
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
}
private static function sanitizeString(string $value): string
{
$candidate = trim($value);
if ($candidate === '') {
return $value;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
return self::REDACTED;
}
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
return self::REDACTED;
}
return $value;
}
}

View File

@ -27,6 +27,9 @@ class Capabilities
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage'; public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
// Managed tenant onboarding
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
// Tenants // Tenants
public const TENANT_VIEW = 'tenant.view'; public const TENANT_VIEW = 'tenant.view';

View File

@ -36,6 +36,9 @@ final class BadgeCatalog
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
]; ];
/** /**

View File

@ -28,4 +28,7 @@ enum BadgeDomain: string
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConnectionStatus = 'provider_connection.status'; case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health'; case ProviderConnectionHealth = 'provider_connection.health';
case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity';
case VerificationReportOverall = 'verification_report_overall';
} }

View File

@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'), 'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'), 'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'), 'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckSeverity;
final class VerificationCheckSeverityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckStatus;
final class VerificationCheckStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

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;
use App\Support\Verification\VerificationReportOverall;
final class VerificationReportOverallBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use Closure;
use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;
use Throwable;
/**
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
*
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
* + WorkspaceCapabilityResolver.
*
* Rules:
* - Non-member hidden UI + 404 server-side
* - Member without capability visible-but-disabled + tooltip + 403 server-side
* - Member with capability enabled
*/
final class WorkspaceUiEnforcement
{
private Action $action;
private bool $requireMembership = true;
private ?string $capability = null;
private bool $isDestructive = false;
private ?string $customTooltip = null;
private Model|Closure|null $record = null;
private function __construct(Action $action)
{
$this->action = $action;
}
/**
* Create enforcement for a table action.
*
* @param Action $action The Filament action to wrap
* @param Model|Closure $record The owner record or a closure that returns it
*/
public static function forTableAction(Action $action, Model|Closure $record): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
return $this;
}
/**
* @throws \InvalidArgumentException If capability is not in the canonical registry
*/
public function requireCapability(string $capability): self
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException(
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
);
}
$this->capability = $capability;
return $this;
}
public function destructive(): self
{
$this->isDestructive = true;
return $this;
}
public function tooltip(string $message): self
{
$this->customTooltip = $message;
return $this;
}
public function apply(): Action
{
$this->applyVisibility();
$this->applyDisabledState();
$this->applyDestructiveConfirmation();
$this->applyServerSideGuard();
return $this->action;
}
private function applyVisibility(): void
{
if (! $this->requireMembership) {
return;
}
$this->action->visible(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
return $context->isMember;
});
}
private function applyDisabledState(): void
{
if ($this->capability === null) {
return;
}
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
return true;
}
return ! $context->hasCapability;
});
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
$context = $this->resolveContextWithRecord($record);
if ($context->isMember && ! $context->hasCapability) {
return $tooltip;
}
return null;
});
}
private function applyDestructiveConfirmation(): void
{
if (! $this->isDestructive) {
return;
}
$this->action->requiresConfirmation();
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
}
private function applyServerSideGuard(): void
{
$this->action->before(function (?Model $record = null): void {
$context = $this->resolveContextWithRecord($record);
if ($context->shouldDenyAsNotFound()) {
abort(404);
}
if ($context->shouldDenyAsForbidden()) {
abort(403);
}
});
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{
$user = auth()->user();
$workspace = $this->resolveWorkspaceWithRecord($record);
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return new WorkspaceAccessContext(
user: null,
workspace: null,
isMember: false,
hasCapability: false,
);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
$isMember = $resolver->isMember($user, $workspace);
$hasCapability = true;
if ($this->capability !== null && $isMember) {
$hasCapability = $resolver->can($user, $workspace, $this->capability);
}
return new WorkspaceAccessContext(
user: $user,
workspace: $workspace,
isMember: $isMember,
hasCapability: $hasCapability,
);
}
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
{
if ($record instanceof Workspace) {
return $record;
}
if ($this->record !== null) {
try {
$resolved = $this->record instanceof Closure
? ($this->record)()
: $this->record;
if ($resolved instanceof Workspace) {
return $resolved;
}
} catch (Throwable) {
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckSeverity: string
{
case Info = 'info';
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckStatus: string
{
case Pass = 'pass';
case Fail = 'fail';
case Warn = 'warn';
case Skip = 'skip';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Support\Verification;
enum VerificationReportOverall: string
{
case Ready = 'ready';
case NeedsAttention = 'needs_attention';
case Blocked = 'blocked';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,358 @@
<?php
namespace App\Support\Verification;
final class VerificationReportSanitizer
{
/**
* @var array<int, string>
*/
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token',
'refresh_token',
'client_secret',
'authorization',
'password',
'cookie',
'set-cookie',
];
/**
* @return array<string, mixed>
*/
public static function sanitizeReport(array $report): array
{
$sanitized = [];
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
if ($schemaVersion !== null) {
$sanitized['schema_version'] = $schemaVersion;
}
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
if ($flow !== null) {
$sanitized['flow'] = $flow;
}
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
if ($generatedAt !== null) {
$sanitized['generated_at'] = $generatedAt;
}
if (is_array($report['identity'] ?? null)) {
$identity = self::sanitizeIdentity((array) $report['identity']);
if ($identity !== []) {
$sanitized['identity'] = $identity;
}
}
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
$summary = self::sanitizeSummary($summary);
if ($summary !== null) {
$sanitized['summary'] = $summary;
}
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
$checks = self::sanitizeChecks($checks);
if ($checks !== null) {
$sanitized['checks'] = $checks;
}
return $sanitized;
}
/**
* @param array<string, mixed> $identity
* @return array<string, int|string>
*/
private static function sanitizeIdentity(array $identity): array
{
$sanitized = [];
foreach ($identity as $key => $value) {
if (! is_string($key) || trim($key) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($key)) {
continue;
}
if (is_int($value)) {
$sanitized[$key] = $value;
continue;
}
if (! is_string($value)) {
continue;
}
$value = self::sanitizeValueString($value);
if ($value !== null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* @param array<string, mixed> $summary
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
*/
private static function sanitizeSummary(array $summary): ?array
{
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return null;
}
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
return null;
}
}
return [
'overall' => $overall,
'counts' => [
'total' => $counts['total'],
'pass' => $counts['pass'],
'fail' => $counts['fail'],
'warn' => $counts['warn'],
'skip' => $counts['skip'],
'running' => $counts['running'],
],
];
}
/**
* @param array<int, mixed> $checks
* @return array<int, array<string, mixed>>|null
*/
private static function sanitizeChecks(array $checks): ?array
{
if ($checks === []) {
return [];
}
$sanitized = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
if ($key === null || $title === null || $reasonCode === null) {
continue;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
continue;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
continue;
}
$messageRaw = $check['message'] ?? null;
if (! is_string($messageRaw) || trim($messageRaw) === '') {
continue;
}
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
$sanitized[] = [
'key' => $key,
'title' => $title,
'status' => $status,
'severity' => $severity,
'blocking' => $blocking,
'reason_code' => $reasonCode,
'message' => self::sanitizeMessage($messageRaw),
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
];
}
return $sanitized;
}
/**
* @param array<int, mixed> $evidence
* @return array<int, array{kind: string, value: int|string}>
*/
private static function sanitizeEvidence(array $evidence): array
{
$sanitized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = $pointer['kind'] ?? null;
if (! is_string($kind) || trim($kind) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
$value = $pointer['value'] ?? null;
if (is_int($value)) {
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
continue;
}
if (! is_string($value)) {
continue;
}
$sanitizedValue = self::sanitizeValueString($value);
if ($sanitizedValue === null) {
continue;
}
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
}
return $sanitized;
}
/**
* @param array<int, mixed> $nextSteps
* @return array<int, array{label: string, url: string}>
*/
private static function sanitizeNextSteps(array $nextSteps): array
{
$sanitized = [];
foreach ($nextSteps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$sanitized[] = [
'label' => $label,
'url' => $url,
];
}
return $sanitized;
}
private static function sanitizeMessage(mixed $message): string
{
if (! is_string($message)) {
return '—';
}
$message = trim(str_replace(["\r", "\n"], ' ', $message));
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
$message = trim($message);
return $message === '' ? '—' : substr($message, 0, 240);
}
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
if (self::containsForbiddenKeySubstring($value)) {
return $fallback;
}
return substr($value, 0, 200);
}
private static function sanitizeValueString(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
return null;
}
if (strlen($value) > 512) {
return null;
}
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
return null;
}
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return null;
}
}
return $value;
}
private static function containsForbiddenKeySubstring(string $value): bool
{
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,235 @@
<?php
namespace App\Support\Verification;
use DateTimeImmutable;
final class VerificationReportSchema
{
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
/**
* @return array<string, mixed>|null
*/
public static function normalizeReport(mixed $report): ?array
{
if (! is_array($report)) {
return null;
}
if (! self::isValidReport($report)) {
return null;
}
return $report;
}
/**
* @param array<string, mixed> $report
*/
public static function isValidReport(array $report): bool
{
$schemaVersion = self::schemaVersion($report);
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
return false;
}
if (! self::isNonEmptyString($report['flow'] ?? null)) {
return false;
}
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
return false;
}
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
return false;
}
$summary = $report['summary'] ?? null;
if (! is_array($summary)) {
return false;
}
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return false;
}
$counts = $summary['counts'] ?? null;
if (! is_array($counts)) {
return false;
}
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
return false;
}
}
$checks = $report['checks'] ?? null;
if (! is_array($checks)) {
return false;
}
foreach ($checks as $check) {
if (! is_array($check) || ! self::isValidCheckResult($check)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $report
*/
public static function schemaVersion(array $report): ?string
{
$candidate = $report['schema_version'] ?? null;
if (! is_string($candidate)) {
return null;
}
$candidate = trim($candidate);
if ($candidate === '') {
return null;
}
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
return null;
}
return $candidate;
}
public static function isSupportedSchemaVersion(string $schemaVersion): bool
{
$parts = explode('.', $schemaVersion, 3);
if (count($parts) !== 3) {
return false;
}
$major = (int) $parts[0];
return $major === 1;
}
/**
* @param array<string, mixed> $check
*/
private static function isValidCheckResult(array $check): bool
{
if (! self::isNonEmptyString($check['key'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['title'] ?? null)) {
return false;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
return false;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
return false;
}
if (! is_bool($check['blocking'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['message'] ?? null)) {
return false;
}
$evidence = $check['evidence'] ?? null;
if (! is_array($evidence)) {
return false;
}
foreach ($evidence as $pointer) {
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
return false;
}
}
$nextSteps = $check['next_steps'] ?? null;
if (! is_array($nextSteps)) {
return false;
}
foreach ($nextSteps as $step) {
if (! is_array($step) || ! self::isValidNextStep($step)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $pointer
*/
private static function isValidEvidencePointer(array $pointer): bool
{
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
return false;
}
$value = $pointer['value'] ?? null;
return is_int($value) || self::isNonEmptyString($value);
}
/**
* @param array<string, mixed> $step
*/
private static function isValidNextStep(array $step): bool
{
if (! self::isNonEmptyString($step['label'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($step['url'] ?? null)) {
return false;
}
return true;
}
private static function isNonEmptyString(mixed $value): bool
{
return is_string($value) && trim($value) !== '';
}
private static function isNonNegativeInt(mixed $value): bool
{
return is_int($value) && $value >= 0;
}
private static function isIsoDateTimeString(mixed $value): bool
{
if (! self::isNonEmptyString($value)) {
return false;
}
try {
new DateTimeImmutable((string) $value);
return true;
} catch (\Throwable) {
return false;
}
}
}

View File

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\OperationRun;
final class VerificationReportWriter
{
/**
* Baseline reason code taxonomy (v1).
*
* @var array<int, string>
*/
private const array BASELINE_REASON_CODES = [
'ok',
'not_applicable',
'missing_configuration',
'permission_denied',
'authentication_failed',
'throttled',
'dependency_unreachable',
'invalid_state',
'unknown_error',
];
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function write(OperationRun $run, array $checks, array $identity = []): array
{
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
$report = self::build($flow, $checks, $identity);
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
}
$context = is_array($run->context) ? $run->context : [];
$context['verification_report'] = $report;
$run->update(['context' => $context]);
return $report;
}
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function build(string $flow, array $checks, array $identity = []): array
{
$flow = trim($flow);
$flow = $flow !== '' ? $flow : 'unknown';
$normalizedChecks = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$normalizedChecks[] = self::normalizeCheckResult($check);
}
$counts = self::deriveCounts($normalizedChecks);
$report = [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow,
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => self::deriveOverall($normalizedChecks, $counts),
'counts' => $counts,
],
'checks' => $normalizedChecks,
];
if ($identity !== []) {
$report['identity'] = $identity;
}
return $report;
}
/**
* @return array<string, mixed>
*/
private static function buildFallbackReport(string $flow): array
{
return [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow !== '' ? $flow : 'unknown',
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
'total' => 0,
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [],
];
}
/**
* @param array<string, mixed> $check
* @return array{
* key: string,
* title: string,
* status: string,
* severity: string,
* blocking: bool,
* reason_code: string,
* message: string,
* evidence: array<int, array{kind: string, value: int|string}>,
* next_steps: array<int, array{label: string, url: string}>
* }
*/
private static function normalizeCheckResult(array $check): array
{
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
return [
'key' => $key,
'title' => $title,
'status' => self::normalizeCheckStatus($check['status'] ?? null),
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
];
}
private static function normalizeCheckStatus(mixed $status): string
{
if (! is_string($status)) {
return VerificationCheckStatus::Fail->value;
}
$status = strtolower(trim($status));
return in_array($status, VerificationCheckStatus::values(), true)
? $status
: VerificationCheckStatus::Fail->value;
}
private static function normalizeCheckSeverity(mixed $severity): string
{
if (! is_string($severity)) {
return VerificationCheckSeverity::Info->value;
}
$severity = strtolower(trim($severity));
return in_array($severity, VerificationCheckSeverity::values(), true)
? $severity
: VerificationCheckSeverity::Info->value;
}
private static function normalizeReasonCode(mixed $reasonCode): string
{
if (! is_string($reasonCode)) {
return 'unknown_error';
}
$reasonCode = strtolower(trim($reasonCode));
if ($reasonCode === '') {
return 'unknown_error';
}
if (str_starts_with($reasonCode, 'ext.')) {
return $reasonCode;
}
$reasonCode = match ($reasonCode) {
'graph_throttled' => 'throttled',
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
'provider_auth_failed' => 'authentication_failed',
'validation_error', 'conflict_detected' => 'invalid_state',
'unknown' => 'unknown_error',
default => $reasonCode,
};
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
}
/**
* @return array<int, array{kind: string, value: int|string}>
*/
private static function normalizeEvidence(mixed $evidence): array
{
if (! is_array($evidence)) {
return [];
}
$normalized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
$value = $pointer['value'] ?? null;
if ($kind === null) {
continue;
}
if (! is_int($value) && ! is_string($value)) {
continue;
}
if (is_string($value) && trim($value) === '') {
continue;
}
$normalized[] = [
'kind' => $kind,
'value' => is_int($value) ? $value : trim($value),
];
}
return $normalized;
}
/**
* @return array<int, array{label: string, url: string}>
*/
private static function normalizeNextSteps(mixed $steps): array
{
if (! is_array($steps)) {
return [];
}
$normalized = [];
foreach ($steps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$normalized[] = [
'label' => $label,
'url' => $url,
];
}
return $normalized;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
*/
private static function deriveCounts(array $checks): array
{
$counts = [
'total' => count($checks),
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
];
foreach ($checks as $check) {
$status = $check['status'] ?? null;
if (! is_string($status) || ! array_key_exists($status, $counts)) {
continue;
}
$counts[$status] += 1;
}
return $counts;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
*/
private static function deriveOverall(array $checks, array $counts): string
{
if (($counts['running'] ?? 0) > 0) {
return VerificationReportOverall::Running->value;
}
if (($counts['total'] ?? 0) === 0) {
return VerificationReportOverall::NeedsAttention->value;
}
foreach ($checks as $check) {
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
return VerificationReportOverall::Blocked->value;
}
}
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
return VerificationReportOverall::NeedsAttention->value;
}
return VerificationReportOverall::Ready->value;
}
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
return $value;
}
}

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -0,0 +1,128 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
DB::transaction(function (): void {
$tenantIds = DB::table('tenants')->whereNull('workspace_id')->pluck('id');
foreach ($tenantIds as $tenantId) {
$workspaceId = DB::table('tenant_memberships')
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
->where('tenant_memberships.tenant_id', $tenantId)
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
->value('workspace_memberships.workspace_id');
if ($workspaceId !== null) {
DB::table('tenants')
->where('id', $tenantId)
->update(['workspace_id' => (int) $workspaceId]);
}
}
$remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count();
if ($remaining === 0) {
return;
}
$legacyWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Legacy Workspace',
'slug' => 'legacy',
'created_at' => now(),
'updated_at' => now(),
]);
$users = DB::table('tenant_memberships')
->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id')
->whereNull('tenants.workspace_id')
->select([
'tenant_memberships.user_id',
DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"),
])
->groupBy('tenant_memberships.user_id')
->get();
$roleFromRank = static fn (int $rank): string => match ($rank) {
0 => 'owner',
1 => 'manager',
2 => 'operator',
default => 'readonly',
};
$membershipRows = [];
foreach ($users as $user) {
$membershipRows[] = [
'workspace_id' => (int) $legacyWorkspaceId,
'user_id' => (int) $user->user_id,
'role' => $roleFromRank((int) $user->role_rank),
'created_at' => now(),
'updated_at' => now(),
];
}
if ($membershipRows !== []) {
DB::table('workspace_memberships')->insertOrIgnore($membershipRows);
}
DB::table('tenants')
->whereNull('workspace_id')
->update(['workspace_id' => (int) $legacyWorkspaceId]);
});
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL');
}
}
};

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -0,0 +1,178 @@
@php
$report = isset($getState) ? $getState() : ($report ?? null);
$report = is_array($report) ? $report : null;
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = $summary['counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
@endphp
<div class="space-y-4">
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-white">
Verification report unavailable
</div>
<div class="mt-1">
This run doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
</div>
</div>
@else
@php
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
<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>
@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)
@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 : [];
@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">
{{ $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
</div>
@endif
@endif
</div>

View File

@ -13,25 +13,10 @@
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200"> <div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div> <div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300"> <div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if ($this->canRegisterTenant())
Register a tenant for this workspace, or switch workspaces.
@else
Switch workspaces, or contact an administrator. Switch workspaces, or contact an administrator.
@endif
</div> </div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row"> <div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('filament.admin.tenant.registration') }}"
>
Register tenant
</x-filament::button>
@endif
<x-filament::button <x-filament::button
type="button" type="button"
color="gray" color="gray"

View File

@ -0,0 +1,170 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">
Managed tenant onboarding
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This wizard will guide you through identifying a managed tenant and verifying access.
</div>
</div>
@if ($this->managedTenant)
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
</div>
</dl>
</div>
@endif
@php
$verificationSucceeded = $this->verificationSucceeded();
$hasTenant = (bool) $this->managedTenant;
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
@endphp
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 Identify managed tenant</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
</div>
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasTenant ? 'Done' : 'Pending' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
wire:click="mountAction('identifyManagedTenant')"
>
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 Provider connection</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
</div>
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
</div>
</div>
@if ($hasTenant)
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('createProviderConnection')"
>
Create connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('selectProviderConnection')"
>
Select connection
</x-filament::button>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 Verify access</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
:disabled="! $hasConnection"
wire:click="mountAction('startVerification')"
>
Run verification
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 Bootstrap (optional)</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
:disabled="! $verificationSucceeded"
wire:click="mountAction('startBootstrap')"
>
Start bootstrap
</x-filament::button>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 Complete onboarding</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="success"
:disabled="! $verificationSucceeded"
wire:click="mountAction('completeOnboarding')"
>
Complete onboarding
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -17,16 +17,14 @@
</div> </div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row"> <div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button <x-filament::button
type="button" type="button"
color="primary" color="primary"
tag="a" tag="a"
href="{{ route('filament.admin.tenant.registration') }}" href="{{ route('admin.workspace.managed-tenants.onboarding', ['workspace' => $this->workspace->slug ?? $this->workspace->getKey()]) }}"
> >
Add managed tenant Start onboarding
</x-filament::button> </x-filament::button>
@endif
<x-filament::button <x-filament::button
type="button" type="button"

View File

@ -1,6 +1,5 @@
<?php <?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\Auth\EntraController;
@ -29,7 +28,7 @@
Route::get('/admin/consent/start', TenantOnboardingController::class) Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start'); ->name('admin.consent.start');
// Panel root override: keep the app's workspace-first flow. // Panel root override: keep the app's workspace-first flow.
// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant // Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
// when no default tenant can be resolved. // when no default tenant can be resolved.
Route::middleware([ Route::middleware([
'web', 'web',
@ -67,7 +66,7 @@
$tenantCount = (int) $tenantsQuery->count(); $tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) { if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
} }
if ($tenantCount === 1) { if ($tenantCount === 1) {
@ -81,23 +80,6 @@
return redirect()->to('/admin/choose-tenant'); return redirect()->to('/admin/choose-tenant');
}) })
->name('admin.home'); ->name('admin.home');
// Fallback route: Filament's layout generates this URL when tenancy registration is enabled.
// In this app, package route registration may not always define it early enough, which breaks
// rendering on tenant-scoped routes.
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->prefix('/admin')
->name('filament.admin.')
->get('/register-tenant', RegisterTenant::class)
->name('tenant.registration');
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
->name('admin.rbac.start'); ->name('admin.rbac.start');
@ -112,42 +94,6 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/managed-tenants', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants');
})
->name('admin.legacy.managed-tenants.index');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/managed-tenants/onboarding', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
})
->name('admin.legacy.managed-tenants.onboarding');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/new', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
})
->name('admin.legacy.onboarding');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/admin/switch-workspace', SwitchWorkspaceController::class) ->post('/admin/switch-workspace', SwitchWorkspaceController::class)
->name('admin.switch-workspace'); ->name('admin.switch-workspace');
@ -173,11 +119,20 @@
->name('admin.workspace.home'); ->name('admin.workspace.home');
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant'))
->name('admin.workspace.managed-tenants.onboarding');
}); });
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
])
->get('/admin/w/{workspace}/managed-tenants/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
->name('admin.workspace.managed-tenants.onboarding');
Route::middleware([ Route::middleware([
'web', 'web',
'panel:admin', 'panel:admin',

View File

@ -29,6 +29,10 @@ ## Security hardening (owners / audit / recovery)
- [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata. - [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata.
- [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited). - [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited).
## Follow-up bugfix
- [x] T300 Fix Workspaces → Memberships UI enforcement to use workspace capabilities (not tenant capabilities).
- [x] T310 Add regression tests for WorkspaceMemberships relation manager action enable/disable.
## Validation ## Validation
- [x] T900 Run Pint on dirty files. - [x] T900 Run Pint on dirty files.
- [x] T910 Run targeted Pest tests. - [x] T910 Run targeted Pest tests.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Unified Managed Tenant Onboarding Wizard (073)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-03
**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
- 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`

View File

@ -0,0 +1,50 @@
openapi: 3.1.0
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.
servers:
- url: https://example.invalid
paths:
/admin/w/{workspace}/managed-tenants:
get:
summary: Managed tenants landing (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema:
type: string
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.
'403':
description: Workspace member missing onboarding capability.
'404':
description: Workspace not found or user not a member (deny-as-not-found).
/admin/register-tenant:
get:
summary: Legacy tenant registration entry point
deprecated: true
responses:
'404':
description: Must be removed / behave as not found (FR-001).

View File

@ -0,0 +1,57 @@
# Onboarding Wizard — Action Contracts (073)
These are conceptual contracts for the wizards server-side actions (Livewire/Filament).
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)`.
- **Inputs:**
- `entra_tenant_id` (string)
- `name` (string)
- `domain` (string|null)
- **Outputs:**
- `tenant_id` (internal DB id)
- `onboarding_session_id`
- `current_step`
- **Errors:**
- 404: workspace not found or actor not a workspace member
- 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.
- **Inputs:**
- `provider_connection_id` (int|null)
- (optional) connection creation fields (non-secret identifiers only)
- **Outputs:**
- `provider_connection_id`
- `is_default`
- **Errors:**
- 404: connection/tenant not in workspace scope
- 403: member missing capability
## Start verification
- **Purpose:** Start provider connection verification asynchronously.
- **Mechanism:** Create/reuse `OperationRun` of type `provider.connection.check`, enqueue `ProviderConnectionHealthCheckJob`.
- **Inputs:** none (uses selected connection)
- **Outputs:**
- `operation_run_id`
- `status` (queued/running/succeeded/failed)
- **Errors:**
- 404: tenant/connection not in workspace scope
- 403: member missing capability
## Optional bootstrap actions
- **Purpose:** Start selected post-verify operations as separate runs.
- **Inputs:** list of operation types (must exist in registry)
- **Outputs:** list of `operation_run_id`
- **Errors:**
- 403/404 semantics as above
## Security & data minimization
- Stored secrets must never be returned.
- Failures are stored as stable reason codes + sanitized messages.

View File

@ -0,0 +1,84 @@
# Data Model — Unified Managed Tenant Onboarding Wizard (073)
## Entities
### Workspace
Existing entity. Onboarding is always initiated within a selected workspace.
### Tenant (Managed Tenant)
Existing model: `App\Models\Tenant`
**Key fields (existing or to be confirmed/extended):**
- `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)
- `name` (string)
- `domain` (string|null)
- `status` (string) — v1 lifecycle:
- `pending` (created / onboarding)
- `active` (ready)
- `archived` (no longer managed)
**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`.
**State transitions:**
- `pending``active` after successful verification
- `active``archived` on soft-delete (existing behavior)
- `archived``active` on restore (existing behavior)
### ProviderConnection
Existing model: `App\Models\ProviderConnection`
- Belongs to `Tenant`
- Contains `entra_tenant_id` (string) and default/active flags.
### TenantOnboardingSession (new)
New model/table to persist resumable onboarding state. Must never persist or return secrets.
**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)
- `tenant_name`
- `tenant_domain`
- `selected_provider_connection_id`
- `verification_run_id` (OperationRun id)
- `bootstrap_run_ids` (array)
- `started_by_user_id` (FK users)
- `updated_by_user_id` (FK users)
- `completed_at` (timestamp|null)
- timestamps
**Constraints:**
- Unique: `(workspace_id, entra_tenant_id)`
**State transitions:**
- `in_progress` (implied by `completed_at = null`) → `completed` (`completed_at != null`)
## 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.
## 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.

View File

@ -0,0 +1,163 @@
# Implementation Plan: Unified Managed Tenant Onboarding Wizard (073)
**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.
## 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.
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.
## 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.
## 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).
## Project Structure
### 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)
```
### 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
├── Models/
│ ├── Tenant.php
│ ├── ProviderConnection.php
│ └── (new) TenantOnboardingSession.php
└── Services/
├── Auth/
│ ├── WorkspaceCapabilityResolver.php
│ └── WorkspaceRoleCapabilityMap.php
├── Providers/
│ ├── ProviderOperationRegistry.php
│ └── ProviderGateway.php
└── Graph/
└── GraphClientInterface.php
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).
## 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.

View File

@ -0,0 +1,35 @@
# Quickstart — Unified Managed Tenant Onboarding Wizard (073)
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install` and `vendor/bin/sail npm install`
- Run migrations: `vendor/bin/sail artisan migrate`
- Run frontend build/dev:
- `vendor/bin/sail npm run dev` (watch)
- or `vendor/bin/sail npm run build`
## Using the wizard (expected flow)
1) Sign in to `/admin`.
2) 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.
Notes:
- The onboarding UI must render DB-only; Graph calls occur only in queued work.
- Verification is tracked as an `OperationRun` (module `health_check`).
## Tests
Run targeted tests (expected file name when implemented):
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`
## Deploy / Ops
If Filament assets are used/registered, deployment must include:
- `php artisan filament:assets`

View File

@ -0,0 +1,62 @@
# Research — Unified Managed Tenant Onboarding Wizard (073)
This document resolves planning unknowns and records key implementation decisions.
## 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.
### 2) Workspace-scoped uniqueness + stable route key
- **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.
### 3) Wizard route location = workspace-scoped (`/admin/w/{workspace}/...`)
- **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.
### 4) Verification implementation = existing provider operation (`provider.connection.check`)
- **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.
### 5) Authorization surface = workspace capability (Owner+Manager)
- **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.
### 6) Legacy entry points = removed/404 (no redirects)
- **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.
## Open Questions
- None. All technical unknowns required for planning are resolved.

View File

@ -0,0 +1,185 @@
# Feature Specification: Unified Managed Tenant Onboarding Wizard (073)
**Feature Branch**: `073-unified-managed-tenant-onboarding-wizard`
**Created**: 2026-02-03
**Status**: Draft
**Input**: User description: "Single, unified onboarding wizard for Managed Tenants (create/attach connection, verify, optional bootstrap), removing all legacy entry points."
## Clarifications
### Session 2026-02-03
- 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).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Start Managed Tenant onboarding (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.
**Why this priority**: This is the primary entry point and eliminates inconsistent/unsafe creation paths.
**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.
**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.
---
### User Story 2 - Configure a connection and verify access (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.
**Why this priority**: Without a validated connection, the tenant cannot be safely managed.
**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.
**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.
---
### User Story 3 - Resume and complete onboarding (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.
**Why this priority**: Real onboarding often pauses for consent/approvals; resumability reduces rework and errors.
**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.
**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.
---
### 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.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**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.
### 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.
## 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).

View File

@ -0,0 +1,159 @@
---
description: "Tasks for Unified Managed Tenant Onboarding Wizard (073)"
---
# Tasks: Unified Managed Tenant Onboarding Wizard (073)
**Input**: Design documents from `specs/073-unified-managed-tenant-onboarding-wizard/`
**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 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all user stories (authz, data model, safety 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)
**Checkpoint**: Foundational complete — user story work can begin.
---
## Phase 3: User Story 1 — Start Managed Tenant 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.
**Independent Test**: Start onboarding in an empty workspace and complete “Identify Managed Tenant”; assert exactly one tenant exists and a session is created/resumed.
- [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
### Remove legacy entry points (required by FR-001)
- [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)
**Checkpoint**: US1 complete — wizard is the only entry point; onboarding start is safe + idempotent.
---
## Phase 4: User Story 2 — Configure a connection and verify access (Priority: P2)
**Goal**: Attach or create a Provider Connection and start verification as an `OperationRun` without leaking secrets.
**Independent Test**: Select/create connection, start verification, assert an OperationRun is created and job is dispatched; assert no secret material is returned.
- [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)
**Checkpoint**: US2 complete — verification is observable via OperationRun and secrets are safe.
---
## Phase 5: User Story 3 — Resume and complete onboarding (Priority: P3)
**Goal**: Resume an onboarding session, run optional bootstrap actions, and complete onboarding to activate the tenant.
**Independent Test**: Start onboarding, leave incomplete, resume as a different authorized owner/manager, complete verification + bootstrap, then mark tenant active.
- [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)
**Checkpoint**: US3 complete — onboarding is resumable and completes safely.
---
## 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`)
### 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
---
## Dependencies & Execution Order
### User Story completion order
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).
### Dependency graph
- Phase 1 → Phase 2 → US1 → US2 → US3 → Polish
---
## Parallel execution examples
### US1 parallel work
- [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).
---
## Implementation Strategy (MVP)
- MVP scope is US1 only: wizard-only entry point + idempotent tenant identification + resumable session skeleton + required authorization semantics + tests.

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Verification Checklist Framework (Enterprise-Ready)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-03
**Feature**: [specs/074-verification-checklist/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass (2026-02-03): Spec avoids framework specifics and focuses on contract + UX outcomes. Next step is planning to translate these requirements into a minimal set of deliverables (report schema, viewer, authorization semantics, audit events, and adoption points).

View File

@ -0,0 +1,47 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"identity": {
"provider_connection_id": 123
},
"summary": {
"overall": "blocked",
"counts": {
"total": 2,
"pass": 1,
"fail": 1,
"warn": 0,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.token_acquisition",
"title": "Token acquisition works",
"status": "fail",
"severity": "high",
"blocking": true,
"reason_code": "authentication_failed",
"message": "The app cannot acquire a token with the configured credentials.",
"evidence": [
{ "kind": "provider_connection_id", "value": 123 }
],
"next_steps": [
{ "label": "Review connection credentials", "url": "/admin/provider-connections/123/edit" }
]
},
{
"key": "provider_connection.permissions",
"title": "Required permissions are granted",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The configured app permissions meet the required baseline.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,29 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "ready",
"counts": {
"total": 1,
"pass": 1,
"fail": 0,
"warn": 0,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,51 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "running",
"counts": {
"total": 3,
"pass": 1,
"fail": 0,
"warn": 0,
"skip": 0,
"running": 2
}
},
"checks": [
{
"key": "provider_connection.token_acquisition",
"title": "Token acquisition works",
"status": "running",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "Check is currently running.",
"evidence": [],
"next_steps": []
},
{
"key": "provider_connection.permissions",
"title": "Required permissions are granted",
"status": "running",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "Check is currently running.",
"evidence": [],
"next_steps": []
},
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,42 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "needs_attention",
"counts": {
"total": 2,
"pass": 1,
"fail": 0,
"warn": 1,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.optional_metadata",
"title": "Optional metadata is present",
"status": "warn",
"severity": "medium",
"blocking": false,
"reason_code": "missing_configuration",
"message": "Some optional metadata is missing; this may reduce diagnostics quality.",
"evidence": [],
"next_steps": [
{ "label": "Open provider connection settings", "url": "/admin/provider-connections" }
]
},
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,26 @@
# Reason Codes (074)
This file defines the baseline `reason_code` taxonomy for verification check results.
## Rules
- Reason codes are **stable** and **machine-readable**.
- New codes must be appended (avoid renames) to keep support and automation stable.
- Flow/check-specific codes must use the reserved namespace: `ext.*`.
## Baseline Codes (v1)
- `ok` — Check passed.
- `not_applicable` — Check skipped because it doesnt apply to this identity/scope.
- `missing_configuration` — Required config is absent.
- `permission_denied` — Insufficient permissions / consent missing.
- `authentication_failed` — Token acquisition or auth precondition failed.
- `throttled` — Remote dependency throttled (e.g., 429/503) and check could not complete.
- `dependency_unreachable` — Remote dependency unavailable.
- `invalid_state` — Local model state conflicts with required preconditions.
- `unknown_error` — Failure could not be classified.
## Reserved Extension Namespace
- `ext.<flow>.<detail>` — Flow-specific extensions.
- Example: `ext.managed_tenant_onboarding.role_mapping_missing`

View File

@ -0,0 +1,128 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-report.schema.json",
"title": "VerificationReport",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"flow",
"generated_at",
"summary",
"checks"
],
"properties": {
"schema_version": {
"type": "string",
"description": "Version of the verification report schema (SemVer)."
},
"flow": {
"type": "string",
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"identity": {
"type": "object",
"description": "Scope identifiers for what is being verified.",
"additionalProperties": true
},
"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": {
"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,61 @@
# Data Model: Verification Checklist Framework (074)
## Overview
This feature introduces a *versioned verification report document* attached to an existing `OperationRun`.
No new database tables are required for v1.
## Existing Entities Used
### OperationRun (`operation_runs`)
Selected fields:
- `id`
- `tenant_id`
- `user_id`
- `type` (used as the verification flow identifier)
- `status` (`queued` | `running` | `completed`)
- `outcome` (`pending` | `succeeded` | `failed`)
- `summary_counts` (JSONB)
- `failure_summary` (JSONB)
- `context` (JSONB)
- `started_at`, `completed_at`
Idempotency:
- DB-enforced dedupe for active runs via partial unique index on `(tenant_id, run_identity_hash)` where `status IN ('queued','running')`.
## New Logical Data (stored inside OperationRun context)
### VerificationReport (`operation_runs.context.verification_report`)
- Stored as JSON in `context` under `verification_report`.
- Versioned by `schema_version`.
- Rendered DB-only (no external calls during view).
High-level shape (see `contracts/verification-report.schema.json` for the canonical contract):
- `schema_version`
- `flow` (identifier; for v1 this can align with `operation_runs.type`)
- `identity` (scope identifiers such as `tenant_id`, `provider_connection_id`, etc.)
- `generated_at`
- `summary` (counts, overall state)
- `checks[]` (check results)
### CheckResult (within `checks[]`)
- `key`, `title`
- `status`: `pass|fail|warn|skip|running`
- `severity`: `info|low|medium|high|critical`
- `blocking`: boolean
- `reason_code`
- `message`
- `evidence[]`: safe pointers only
- `next_steps[]`: links only in v1
## Audit
Verification start and completion are recorded in `audit_logs` using stable `action` identifiers (via `App\Support\Audit\AuditActionId`). Metadata is minimal and sanitized.
## Notes / Constraints
- Viewer must be DB-only: rendering the report must not dispatch jobs or perform HTTP.
- Evidence must be redacted/safe: no secrets/tokens/payload dumps in stored or rendered report.

View File

@ -0,0 +1,127 @@
# Implementation Plan: Verification Checklist Framework (Enterprise-Ready)
**Branch**: `074-verification-checklist` | **Date**: 2026-02-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/074-verification-checklist/spec.md`
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
## Summary
- Introduce a versioned “verification report” contract that can be attached to an existing `OperationRun` and rendered consistently across multiple flows.
- Provide a reusable, DB-only report viewer (no outbound calls during render/hydration/poll) that presents summary + per-check statuses + safe evidence pointers + navigation-only next steps.
- Enforce enterprise semantics: stable reason codes, strict evidence redaction, deterministic active-run dedupe, and capability-first authorization aligned with RBAC-UX (non-members 404; members missing start capability 403).
- Emit audit events for verification start + completion using stable action identifiers with redacted metadata.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`)
**Testing**: Pest (PHPUnit)
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
**Project Type**: web
**Performance Goals**: Verification viewer renders fast from DB-only JSON (typical report ≤ 50 checks)
**Constraints**: Viewer is read-only and must not trigger any outbound HTTP or job dispatch; evidence must not contain secrets/tokens/payloads; new status-like badges must use centralized BADGE-001 mapping.
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding + provider ops + future readiness flows
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first, snapshots-second: PASS (this feature is run/report UX; no inventory semantics changed).
- Read/write separation: PASS (viewer is read-only; start surfaces enqueue-only and already follow `OperationRun` patterns).
- Graph contract path: PASS (viewer performs no Graph calls; verification execution remains in queued jobs that already follow provider gateway patterns).
- Deterministic capabilities: PASS (start/view gates reference the existing capability registry; no role-string checks).
- RBAC-UX: PASS (non-member tenant access is 404; member-but-missing-capability is 403; server-side gates enforce mutations/starts).
- Run observability: PASS (verification is represented as `OperationRun`; active-run dedupe enforced by the existing partial unique index on `(tenant_id, run_identity_hash)` for active statuses).
- Data minimization: PASS (report evidence constrained to safe pointers; audit metadata redacted; no secrets in stored report).
- Badge semantics (BADGE-001): PASS (plan includes adding a centralized badge domain for check statuses/severity; no ad-hoc UI mappings).
## Project Structure
### Documentation (this feature)
```text
specs/074-verification-checklist/
├── 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)
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
config/
database/
resources/
routes/
tests/
```
**Structure Decision**: Single Laravel web application with Filament admin panel. The framework is implemented as:
- contract + helpers under `app/Support/**`
- report writer invoked from queued jobs under `app/Jobs/**` / `app/Services/**`
- viewer UI as Filament schema components and Blade views under `app/Filament/**` and `resources/views/filament/**`
- authorization via existing capabilities/gates/policies
## Complexity Tracking
No constitution violations required for this feature.
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Goals:
- Confirm the canonical storage location for the report (DB-only render) using existing `operation_runs.context` JSONB.
- Confirm active-run dedupe behavior and ensure it matches the specs “dedupe while active only” requirement.
- Confirm the correct approach for status-like UI badges in Filament (BADGE-001), so the viewer doesnt introduce ad-hoc mappings.
- Confirm the existing audit logger + redaction utilities and define stable action IDs for verification completion.
## 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 schema: versioned JSON document (checks + counts + timestamps + next steps) stored in `operation_runs.context.verification_report`.
- Reason codes: baseline set + reserved `ext.*` namespace.
- Evidence redaction: strict sanitizer so reports never store or render secrets/tokens/payloads.
- Viewer: reusable Filament view entry / component that renders summary + per-check details without any outbound calls.
- Authorization: view allowed for tenant-scoped members; start requires capability; non-member access is deny-as-not-found.
- Auditing: start + completion events logged with minimal redacted metadata.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
- Contract: create a canonical report schema (JSON Schema + example) and a small baseline reason-code list.
- Writer: add a `VerificationReportWriter` (or equivalent) that normalizes check results, enforces redaction rules, and writes the report into `OperationRun->context`.
- Viewer: add a reusable Filament UI renderer (Blade view + helper) that displays summary, counts, statuses, and next steps (links-only).
- Integration points:
- Show the verification report section in Monitoring → Operations run detail when present.
- Embed the same viewer in onboarding and provider connection verification flows.
- RBAC & UX:
- Enforce “view vs start” split (view allowed for tenant members; start capability required).
- Preserve RBAC-UX semantics (non-members 404; members missing capability 403).
- Audit:
- Keep existing start audit event; add a completion audit event emitted when the verification run finalizes.
- Tests (Pest):
- Viewer is DB-only (Http::fake + render assertion).
- Evidence redaction rules (report contains no forbidden keys/values).
- Dedupe semantics reuse active run (leveraging the existing partial unique index behavior).
## Constitution Check (Post-Design)
Re-check result: PASS. Design artifacts keep verification viewing DB-only, align with run observability + dedupe, enforce RBAC-UX semantics, and centralize status badge mappings.

View File

@ -0,0 +1,79 @@
# Quickstart: Verification Checklist Framework (074)
This quickstart explains how to *write* and *render* a verification report attached to an `OperationRun`.
## 1) Writing a report (queued job / service)
**Goal**: produce a `verification_report` JSON document and store it in `OperationRun->context`.
Guidelines:
- Generate reports inside queued execution (not in a Filament page render).
- Keep evidence pointer-only (IDs/masked/hashes), never raw payloads or tokens.
- Keep next steps navigation-only in v1.
Pseudo-code sketch:
```php
$context = is_array($run->context) ? $run->context : [];
$context['verification_report'] = [
'schema_version' => '1.0',
'flow' => $run->type,
'generated_at' => now('UTC')->toIso8601String(),
'identity' => [
'tenant_id' => (int) $run->tenant_id,
'provider_connection_id' => (int) data_get($run->context, 'provider_connection_id', 0),
],
'summary' => [
'overall' => 'needs_attention',
'counts' => [
'total' => 5,
'pass' => 3,
'fail' => 2,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [
[
'key' => 'provider_connection.token_acquisition',
'title' => 'Token acquisition works',
'status' => 'fail',
'severity' => 'high',
'blocking' => true,
'reason_code' => 'permission_denied',
'message' => 'The app cannot acquire a token with the configured credentials.',
'evidence' => [
['kind' => 'provider_connection_id', 'value' => (int) data_get($run->context, 'provider_connection_id')],
],
'next_steps' => [
['label' => 'Review connection credentials', 'url' => '/admin/...'],
['label' => 'Microsoft docs: app permissions', 'url' => 'https://learn.microsoft.com/...'],
],
],
],
];
$run->update(['context' => $context]);
```
## 2) Rendering the report (Filament, DB-only)
Recommended integration points:
- Monitoring → Operations: in the `OperationRun` view page, show a “Verification report” section when `context.verification_report` exists.
- Flow pages (e.g., onboarding wizard): embed the same viewer component using the run ID stored in wizard state.
**Hard requirement**: rendering must not trigger any outbound HTTP (no Graph calls, no jobs dispatched, no side effects).
## 3) Authorization split
- Viewing a report: allowed for tenant-scoped members.
- Starting verification: requires a specific capability.
- Non-members: deny-as-not-found (404) for tenant-scoped pages and actions.
## 4) Tests to add
- Viewer DB-only render test: `Http::fake()` + assert no requests during render.
- Evidence redaction test: report JSON contains none of `access_token`, `client_secret`, `Authorization`, bearer tokens, or raw payload dumps.
- Dedupe test: repeated starts while active reuse the same run.

View File

@ -0,0 +1,86 @@
# Research: Verification Checklist Framework (074)
**Date**: 2026-02-03
**Phase**: Phase 0 (Foundational Research)
**Status**: Complete
---
## Decisions
### D-001 — Canonical storage location for verification reports
**Decision**: Store the verification report in `operation_runs.context.verification_report` (JSONB).
**Rationale**:
- Monitoring pages must be DB-only at render time (constitution: Operations / Run Observability Standard).
- `OperationRun` is the canonical operational record; keeping the report attached avoids new tables/indexing for v1.
- The existing UI already renders `OperationRun.context` safely as JSON, so we can progressively enhance into a structured viewer.
**Alternatives considered**:
- Dedicated `verification_reports` table: rejected for v1 to keep adoption lightweight; can be introduced later if querying/indexing becomes necessary.
---
### D-002 — Idempotency / dedupe mechanism
**Decision**: Use the existing `OperationRunService::ensureRunWithIdentity()` mechanism and the DB partial unique index on `(tenant_id, run_identity_hash)` for active statuses (`queued`, `running`).
**Rationale**:
- This repo already enforces active-run dedupe at the DB level via `operation_runs_active_unique`.
- Matches the clarified spec policy: dedupe only while a run is active; completed runs allow a new run.
**Alternatives considered**:
- Application-only locks/dedupe: rejected as non-race-safe.
---
### D-003 — Flow identifier and identity scope
**Decision**: Treat `OperationRun.type` as the primary flow identifier for the verification run, and keep additional flow details (wizard step, etc.) in `context`.
**Rationale**:
- Existing operations already key UX semantics (labels, polling, related links) off `OperationRun.type`.
- Dedupe identity hashing already includes `type`, making flow part of the dedupe boundary.
**Alternatives considered**:
- Separate `flow_id` column: rejected for v1 (schema change not required).
---
### D-004 — Reason code taxonomy and extensions
**Decision**: Maintain a small baseline set of cross-cutting reason codes, and reserve `ext.*` for flow/check-specific extensions.
**Rationale**:
- Prevents brittle UI parsing and enables future automation.
- Keeps room for flow-specific details without polluting the baseline vocabulary.
**Alternatives considered**:
- Free-form codes everywhere: rejected due to support/automation cost.
---
### D-005 — Evidence policy (strict safe pointers)
**Decision**: Evidence fields in check results are *strictly* structured safe pointers only (IDs, masked strings, hashes). No payloads, tokens, claims, headers, or full error bodies.
**Rationale**:
- Aligns with constitution data-minimization and safe logging rules.
- Avoids accidentally persisting secrets inside run context.
**Alternatives considered**:
- Storing raw error payloads: rejected for security and compliance risk.
---
### D-006 — UI semantics for statuses and badges
**Decision**: Render status-like values (check status, severity) via centralized badge semantics (BADGE-001), not ad-hoc mappings in feature pages.
**Rationale**:
- Prevents drift in meaning/colors across the suite.
- Enables straightforward regression tests for new/changed status values.
**Alternatives considered**:
- Inline color mapping inside a Blade view: rejected (violates BADGE-001).

View File

@ -0,0 +1,186 @@
# Feature Specification: Verification Checklist Framework (Enterprise-Ready)
**Feature Branch**: `074-verification-checklist`
**Created**: 2026-02-03
**Status**: Draft
**Input**: User description: "Replace binary verification UX with a structured, reusable verification checklist attached to verification runs; DB-only viewing; enterprise semantics (reason codes, audit, idempotency, RBAC)."
## Clarifications
### Session 2026-02-03
- Q: What idempotency policy do we want for “Start verification”? → A: Dedupe only while a run is active (queued/running); once completed/failed, “Start verification” creates a new run.
- Q: Who should be allowed to view verification reports? → A: Any authenticated workspace member with access to the tenant scope may view reports; starting verification requires a separate capability.
- Q: What policy should we use for `reason_code` taxonomy? → A: Versioned central taxonomy with a small baseline set + reserved `ext.*` namespace for feature-specific extensions.
- Q: Whats the required evidence/redaction policy for `evidence` in check results? → A: Evidence is strictly structured safe pointers only (internal IDs, masked strings, hashes); never raw payloads, tokens, claims, headers, or full error bodies.
- Q: Should “Next steps” CTAs be links only, or can they trigger server-side actions? → A: Links only (navigation-only) in v1.
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Operator sees whats wrong (Priority: P1)
As a workspace member onboarding or operating a managed tenant, I can run “Verify access” and see a structured checklist that clearly shows which checks passed, which failed, and what to do next.
**Why this priority**: This is the primary value of verification: reduce ambiguity and enable fast, correct remediation.
**Independent Test**: Seed a verification run with a report containing mixed outcomes and confirm the viewer renders an accurate summary, per-check status, and next steps without making any external calls.
**Acceptance Scenarios**:
1. **Given** a completed verification run with 2 failed checks, **When** I open the verification report viewer, **Then** I see an overall summary (“Needs attention” or “Blocked”), counts, and the two failed checks with actionable next steps.
2. **Given** a verification run that is still in progress, **When** I open the viewer, **Then** I see a “Running” state and partial results (if available) without errors.
---
### User Story 2 - Deterministic starts (idempotency / dedupe) (Priority: P2)
As an operator, if I click “Start verification” multiple times for the same tenant + provider connection + flow, the system behaves deterministically: it does not start duplicate active runs and guides me to the already-running run.
**Why this priority**: Prevents confusing duplicates, reduces load, and makes support/debugging repeatable.
**Independent Test**: Attempt to start verification twice for the same identity and assert that only one active run exists and the UI returns a consistent “already running” outcome.
**Acceptance Scenarios**:
1. **Given** an active verification run exists for the same identity, **When** I click “Start verification”, **Then** no duplicate run is started and I am directed to view the active run/report.
2. **Given** no active verification run exists (including when the most recent run is completed or failed), **When** I click “Start verification”, **Then** a new run starts and I can view its report as it progresses.
---
### User Story 3 - Least-privilege and safe disclosure (Priority: P3)
As a workspace member with access to the tenant scope (including read-only), I can view verification reports but cannot start verification unless I have the start capability. As a non-member, I cannot discover that a tenant or report exists.
**Why this priority**: Verification data can leak operational posture; access must follow least-privilege and “deny-as-not-found” for non-members.
**Independent Test**: Validate both authorization paths: read-only can view but cannot start; non-member receives a not-found response for all tenant-scoped verification routes.
**Acceptance Scenarios**:
1. **Given** I am a workspace member without the “start verification” capability, **When** I open the verification page, **Then** I can view past reports but the “Start verification” action is disabled and cannot be executed.
2. **Given** I am not a member of the workspace/tenant scope, **When** I attempt to access the verification report route, **Then** I receive a not-found response with no identifying hints.
---
### Edge Cases
- Report missing or malformed (e.g., run exists but report is absent or partial) → viewer shows a safe “Report unavailable” state and guidance.
- Unknown check keys or unknown reason codes (newer schema written by a newer verifier) → viewer degrades gracefully, still showing status/message/next steps when present.
- Large reports (near upper bound, e.g., 50 checks) → viewer remains responsive and summary counts remain correct.
- A run transitions from running → complete while the user is viewing → the viewer refreshes safely or the user can re-open without inconsistent states.
- Evidence contains unexpected fields → redaction rules prevent sensitive values from being displayed.
## Out of Scope
- Introducing a separate monitoring/observability platform beyond the existing run tracking and audit log.
- Any workflow that requires client-side handling of secrets.
- A full overhaul of onboarding wizards beyond replacing/embedding verification status with the checklist viewer.
- Provider job orchestration redesign unrelated to running verification checks.
- Server-side actions triggered directly from the checklist viewer (v1 is navigation-only).
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any external provider calls, any write/change behavior,
or any long-running/background work, the spec MUST describe safety gates (preview/confirmation/audit), tenant isolation,
run observability (run identity, visibility, and outcomes), and tests. If security-relevant DB-only actions intentionally
skip run tracking, the spec MUST describe the audit log entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant-scoped admin area vs platform/system admin area),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require explicit user confirmation,
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
### Functional Requirements
- **FR-001 — Canonical verification report contract**: The system MUST generate a versioned “Verification Report” document for each verification run, including: schema version, flow identifier, identity/scope, generated timestamp, summary counts, and a list of check results.
- **FR-002 — Check result contract**: Each check result MUST include: stable key, title, status (pass/fail/warn/skip/running), severity (info/low/medium/high/critical), blocking flag, reason code, human-readable message, safe evidence pointers, and one or more “next steps” actions (where applicable).
Evidence MUST be strictly limited to structured safe pointers (internal IDs, masked strings, hashes) and MUST NOT contain raw payloads, tokens, claims, headers, or full error bodies.
Next steps in v1 MUST be navigation-only (links to internal pages or external documentation) and MUST NOT trigger server-side actions.
- **FR-003 — Stable reason code taxonomy**: The system MUST use stable, documented reason codes for failed/warned/skipped outcomes so that support, automation, and future UI changes remain consistent.
The taxonomy MUST include a small baseline set of cross-cutting codes and MUST reserve an `ext.*` namespace for flow-specific or check-specific extensions.
- **FR-004 — DB-only viewing**: Viewing a verification checklist MUST be read-only and MUST NOT trigger any external calls (e.g., no provider API calls, no HTTP calls, no background jobs started as a side effect of rendering).
- **FR-005 — Start verification creates a run**: Starting verification MUST create (or reuse, per dedupe policy) a new verification run record and begin executing the verification checks using existing background processing.
- **FR-006 — Dedupe / idempotency**: If a verification run is already active for the same identity (tenant + provider connection + flow), the system MUST NOT start a duplicate active run; it MUST present a clear “already running” outcome and an affordance to view the active run/report.
If no run is active (including when the most recent run is completed or failed), “Start verification” MUST create a new run.
- **FR-007 — Capability-first authorization**: Permission checks for viewing and starting verification MUST reference the canonical capability registry (no string-literal capability checks in feature code).
- **FR-008 — RBAC UX semantics**: Non-members attempting to access tenant-scoped verification pages/routes MUST receive not-found responses. Members lacking the “start” capability MUST be able to view reports but MUST NOT be able to start verification (UI disabled + server-side enforcement).
Viewing reports MUST NOT require the start capability.
- **FR-009 — Standardized UI semantics**: The viewer MUST render consistent status labels, a summary banner (e.g., Ready / Needs attention / Blocked), and per-check expandable details with standardized “Next steps” calls-to-action.
- **FR-010 — Reuse across suite**: The framework MUST be adoptable by multiple verification flows without re-implementing viewer logic, including: managed tenant onboarding verification, provider connection verification, RBAC setup verification, consent & permission verification, and future readiness/health checks.
- **FR-011 — Auditing**: Starting and completing verification MUST emit audit events with stable action identifiers and redaction rules, recording minimal metadata (workspace/tenant identifiers, run identifier, and result counts).
### Key Entities *(include if feature involves data)*
- **Verification Flow**: A named verification context (e.g., managed tenant onboarding) that defines which checks run.
- **Verification Identity (Scope)**: The set of identifiers that uniquely represent “what is being verified” (tenant + provider connection + flow).
- **Verification Run**: A single execution attempt for a given identity that produces a report (and is auditable).
- **Verification Report**: A versioned, structured document attached to a run, containing summary and check results.
- **Check Definition**: A reusable definition of an atomic readiness check (key, title, expected preconditions, severity, blocking behavior).
- **Check Result**: The outcome of executing a check within a report.
- **Reason Code**: A stable, machine-readable classification of why a check is pass/fail/warn/skip.
- **Next Step**: An actionable remediation hint (label + optional destination/action) that helps the operator resolve a failed check.
- **Evidence Pointer**: Safe references that support diagnostics (IDs, masked strings, hashes), without exposing secrets.
### Assumptions
- A run-tracking mechanism already exists and can store an attached, versioned verification report per run.
- A canonical capability registry exists and is the source of truth for permission checks.
- An audit logging mechanism exists that can record start/complete events with redaction.
- Verification execution uses existing background processing patterns (no new observability platform is introduced).
### Dependencies
- Workspace membership and tenant-scoped authorization boundaries are already modeled.
- Run visibility rules support “deny-as-not-found” behavior for non-members.
- UI surfaces exist (or can be added) where “Next steps” can route users for remediation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Clarity)**: In a usability test with a pre-seeded failed report, 90% of operators can identify the top blocking failure and the recommended next step within 60 seconds.
- **SC-002 (Determinism)**: When “Start verification” is triggered repeatedly for the same identity while a run is active, the system starts at most 1 active run (0 duplicates) and always provides a path to view the active run.
- **SC-003 (Safety / data minimization)**: Verification reports contain no secrets or tokens; evidence is limited to safe pointers (validated by automated tests and/or static checks).
- **SC-004 (Performance)**: The verification report viewer renders within 200ms server time for a typical report of up to 50 checks.
- **SC-005 (Authorization)**: Non-member access to tenant-scoped verification pages results in not-found responses in 100% of tested cases; members without the start capability cannot execute start actions in 100% of tested cases.

View File

@ -0,0 +1,120 @@
# Tasks: 074 Verification Checklist Framework
**Input**: Design documents from `/specs/074-verification-checklist/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Required (Pest).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 [US1] Add example reports under specs: `specs/074-verification-checklist/contracts/examples/*.json` (pass/fail/warn/running) aligned to `contracts/verification-report.schema.json`.
- [x] T002 [P] [US1] Add a small schema validation helper for reports (pure PHP, no external deps) in `app/Support/Verification/VerificationReportSchema.php` (version parsing + shape validation + graceful fallback).
- [x] T003 [P] [US1] Add report redaction/sanitization utility in `app/Support/Verification/VerificationReportSanitizer.php` (denylist keys/values; enforce evidence pointers only).
- [x] T004 [US1] Add value objects (or typed arrays) for report/check concepts in `app/Support/Verification/*` (status/severity enums or constants) to avoid ad-hoc strings throughout UI.
**RBAC & UX prereqs**
- [x] T005 [US3] Decide and document the start capability used per verification flow (v1: use `Capabilities::PROVIDER_RUN` for `provider.connection.check`; prefer existing constants in `app/Support/Auth/Capabilities.php`).
- [x] T006 [US3] Add/confirm central UI enforcement helper usage for “visible-but-disabled with tooltip” in verification start UI (use tenant-scoped `app/Support/Rbac/UiEnforcement.php` with a resolved `Tenant` record).
**Badges (BADGE-001)**
- [x] T007 [P] [US1] Add badge domains for verification status/severity in `app/Support/Badges/BadgeDomain.php`.
- [x] T008 [P] [US1] Add domain mappers in `app/Support/Badges/Domains/*` (e.g., `VerificationCheckStatusBadge`, `VerificationCheckSeverityBadge`).
- [x] T009 [US1] Register domains in `app/Support/Badges/BadgeCatalog.php`.
- [x] T010 [US1] Add mapping tests for new badge domains in `tests/Unit/Badges/*`.
**Checkpoint**: Report contract + sanitizer + badge domains exist; UI work can start.
---
## Phase 2: User Story 1 — Operator sees whats wrong (Priority: P1)
**Goal**: Render a structured, DB-only verification report viewer for a run.
**Independent Test**: Seed an `OperationRun` with `context.verification_report` and assert the viewer renders the correct summary + per-check details, with no outbound HTTP.
### Tests (write first)
- [x] T011 [US1] Add a viewer DB-only test (no outbound HTTP, no job dispatch) in `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` using `Http::fake()` + `Bus::fake()` and asserting no requests / no dispatch during page render (including a second render to cover Livewire refresh/poll paths).
- [x] T012 [US1] Add a redaction test in `tests/Feature/Verification/VerificationReportRedactionTest.php` to ensure forbidden keys/values never appear in stored/rendered evidence.
- [x] T013 [US1] Add a “malformed/missing report” viewer test in `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` (safe empty state).
### Implementation
- [x] T014 [US1] Create a reusable viewer Blade partial in `resources/views/filament/components/verification-report-viewer.blade.php` (summary banner + counts + collapsible checks + next steps links-only).
- [x] T015 [US1] Create a Filament view entry/helper to render the viewer from an `OperationRun` in `app/Filament/Support/VerificationReportViewer.php` (or existing Filament helpers location), using only DB values.
- [x] T016 [US1] Integrate viewer into Monitoring → Operations run view page: update `app/Filament/Resources/OperationRunResource.php` (infolist) to show the verification report section when `context.verification_report` exists.
**Checkpoint**: A seeded report is readable in Monitoring; viewer is DB-only.
---
## Phase 3: User Story 2 — Deterministic starts (Priority: P2)
**Goal**: Starting verification is idempotent while active (dedupe) and guides users to the active run.
**Independent Test**: Start verification twice for the same identity and assert a single active run is used.
### Tests
- [x] T017 [US2] Add a dedupe regression test in `tests/Feature/Verification/VerificationStartDedupeTest.php` asserting repeated starts reuse the same active run (leveraging the existing `OperationRunService::ensureRunWithIdentity()` behavior).
- [x] T018 [US2] Add a “new run after completion” test in `tests/Feature/Verification/VerificationStartAfterCompletionTest.php`.
### Implementation
- [x] T019 [US2] Add (or adapt) a small “start verification” service wrapper in `app/Services/Verification/StartVerification.php` that: authorizes, creates/reuses a run identity, enqueues a verifier job, and returns the run.
- [x] T020 [US2] Update the managed tenant onboarding verification step to route through the shared starter and replace the binary status UI with the shared verification report viewer (or a safe empty state) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`.
- [x] T021 [US2] Update provider connection verification start surface(s) (where present) to route through the same shared starter.
**Checkpoint**: Starts are deterministic and route users to the active run.
---
## Phase 4: User Story 3 — Least-privilege and safe disclosure (Priority: P3)
**Goal**: View vs start capability split; non-members get 404; members lacking start capability get 403 on execution.
**Independent Test**: Readonly member can view report but cannot start; non-member cannot discover tenant/run.
### Tests
- [x] T022 [US3] Add authorization tests for view vs start in `tests/Feature/Verification/VerificationAuthorizationTest.php` covering:
- tenant non-member → 404 on view + start
- tenant member without start capability → can view, start returns forbidden (403)
- tenant member with start capability → can start
### Implementation
- [x] T023 [US3] Ensure start actions enforce server-side authorization via Gate/Policy (no UI-only enforcement) and use capability constants from `app/Support/Auth/Capabilities.php`.
- [x] T024 [US3] Ensure tenant-scope non-membership yields deny-as-not-found behavior for verification routes/actions (align with existing tenant routing patterns and helpers).
**Checkpoint**: Authorization behavior matches RBAC-UX contract.
---
## Phase 5: Audit & Completion Events (Cross-cutting)
- [x] T025 [US1] Add a stable audit action ID for verification completion in `app/Support/Audit/AuditActionId.php`.
- [x] T026 [US1] Emit a completion audit event when a verification run finalizes (where run completion is set) using `app/Services/Audit/WorkspaceAuditLogger.php` with redacted metadata (run id + counts only).
- [x] T030 [US1] Add a report writer in `app/Support/Verification/VerificationReportWriter.php` that builds `context.verification_report`, derives `summary.overall` deterministically, enforces reason codes + evidence pointer-only policy, and runs sanitizer before persistence.
- [x] T031 [US1] Integrate the report writer into `app/Jobs/ProviderConnectionHealthCheckJob.php` so `provider.connection.check` writes a compliant `verification_report` to the run (both success and failure paths) before marking the run completed.
- [x] T032 [US1] Add a report-writing integration test in `tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php` ensuring the run ends with a valid, sanitized `context.verification_report` (and no forbidden evidence fields).
---
## Phase 6: Polish & Regression Guards
- [x] T027 [P] Add UI polish for empty/missing report state in the viewer (no leaks of internal details).
- [x] T028 Run formatting: `vendor/bin/sail bin pint --dirty`.
- [x] T029 Run targeted tests: `vendor/bin/sail artisan test --compact tests/Feature/Verification`.
---
## Dependencies & Execution Order
- Phase 1 (Foundational) blocks all other phases.
- US1 can start after Phase 1; US2/US3 can proceed after Phase 1 but should reuse US1 primitives (viewer + sanitizer + badges).
- Audit completion (Phase 5) depends on the shared verification job/service that finalizes runs.

View File

@ -4,5 +4,5 @@
it('redirects /admin/new to /admin/login for guests', function (): void { it('redirects /admin/new to /admin/login for guests', function (): void {
$this->get('/admin/new') $this->get('/admin/new')
->assertRedirect('/admin/login'); ->assertNotFound();
}); });

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
it('redacts secret-like fields in workspace audit metadata', function (): void {
$workspace = Workspace::factory()->create();
$actor = User::factory()->create();
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: 'test.redaction',
context: [
'metadata' => [
'access_token' => 'super-secret-token',
'client_secret' => 'super-secret-secret',
'nested' => [
'Authorization' => 'Bearer abc.def.ghi',
'safe' => 'ok',
],
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$log = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'test.redaction')
->latest('id')
->firstOrFail();
expect($log->metadata['access_token'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['client_secret'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['nested']['Authorization'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['nested']['safe'] ?? null)->toBe('ok');
});

View File

@ -3,6 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -14,7 +17,18 @@
$this->get('/admin/login')->assertOk(); $this->get('/admin/login')->assertOk();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user);
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
$this->get('/admin/no-access')->assertOk(); $this->get('/admin/no-access')->assertOk();
$this->get('/admin/choose-tenant')->assertOk(); $this->get('/admin/choose-tenant')->assertOk();

View File

@ -4,32 +4,39 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('does not allow a non-member user to access tenant-scoped admin routes', function () { it('does not allow a non-member user to access tenant-scoped admin routes', function () {
$tenant = Tenant::factory()->create(['status' => 'active']); [$member, $tenant] = createUserWithTenant(
tenant: Tenant::factory()->create(['status' => 'active']),
user: User::factory()->create(),
role: 'owner',
);
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
$member = User::factory()->create();
$nonMember = User::factory()->create(); $nonMember = User::factory()->create();
WorkspaceMembership::factory()->create([
TenantMembership::query()->create([ 'workspace_id' => $workspace->getKey(),
'tenant_id' => $tenant->getKey(), 'user_id' => $nonMember->getKey(),
'user_id' => $member->getKey(),
'role' => 'owner', 'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]); ]);
$this->actingAs($nonMember); $this->actingAs($nonMember)
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertNotFound(); ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertNotFound();
$this->actingAs($member); $this->actingAs($member)
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful(); ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertSuccessful();
$this->get('/system')->assertNotFound(); $this->get('/system')->assertNotFound();
}); });

View File

@ -10,7 +10,9 @@
test('backup schedules listing is tenant scoped', function () { test('backup schedules listing is tenant scoped', function () {
[$user, $tenantA] = createUserWithTenant(role: 'manager'); [$user, $tenantA] = createUserWithTenant(role: 'manager');
$tenantB = Tenant::factory()->create(); $tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
@ -46,6 +48,10 @@
$this->actingAs($user); $this->actingAs($user);
// createUserWithTenant() may be called multiple times in this test; ensure the current
// workspace matches the tenant we are about to access.
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA))) $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA)))
->assertOk() ->assertOk()
->assertSee('Tenant A schedule') ->assertSee('Tenant A schedule')

View File

@ -0,0 +1,18 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps pending tenant status to a Pending warning badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'pending');
expect($spec->label)->toBe('Pending');
expect($spec->color)->toBe('warning');
expect($spec->icon)->toBe('heroicon-m-clock');
});
it('normalizes tenant status input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING');
expect($spec->label)->toBe('Pending');
});

View File

@ -90,7 +90,9 @@
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () { test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
$tenantA = Tenant::factory()->create(); $tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create(); $tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
$groupB = EntraGroup::query()->create([ $groupB = EntraGroup::query()->create([
'tenant_id' => $tenantB->getKey(), 'tenant_id' => $tenantB->getKey(),
@ -103,10 +105,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))

View File

@ -18,9 +18,7 @@
]); ]);
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->user->tenants()->syncWithoutDetaching([ [$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner');
$this->tenant->getKey() => ['role' => 'owner'],
]);
}); });
it('renders policy version view without any Graph calls during render', function () { it('renders policy version view without any Graph calls during render', function () {

View File

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

View File

@ -4,34 +4,30 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy detail shows app protection settings in readable sections', function () { test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1', 'external_id' => 'policy-1',
'policy_type' => 'appProtectionPolicy', 'policy_type' => 'appProtectionPolicy',
'display_name' => 'Teams', 'display_name' => 'Teams',
'platform' => 'mobile', 'platform' => 'mobile',
]); ]);
PolicyVersion::create([ PolicyVersion::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -46,11 +42,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -29,7 +29,7 @@
->assertDontSee('Register tenant'); ->assertDontSee('Register tenant');
}); });
it('shows the register-tenant CTA for owner workspace members when there are no tenants', function (): void { it('does not show the register-tenant CTA for owner workspace members when there are no tenants', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -44,5 +44,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant') ->get('/admin/choose-tenant')
->assertSuccessful() ->assertSuccessful()
->assertSee('Register tenant'); ->assertSee('No tenants are available')
->assertSee('Change workspace')
->assertDontSee('Register tenant');
}); });

View File

@ -3,42 +3,30 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
$tenant = Tenant::create([ [$user, $tenant] = createUserWithTenant(role: 'owner');
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$this->tenant = $tenant; $this->tenant = $tenant;
$this->user = User::factory()->create(); $this->user = $user;
$this->user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
}); });
test('policy detail renders normalized settings for Autopilot profiles', function () { test('policy detail renders normalized settings for Autopilot profiles', function () {
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'external_id' => 'autopilot-1', 'external_id' => 'autopilot-1',
'policy_type' => 'windowsAutopilotDeploymentProfile', 'policy_type' => 'windowsAutopilotDeploymentProfile',
'display_name' => 'Autopilot Profile A', 'display_name' => 'Autopilot Profile A',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
PolicyVersion::create([ PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -71,17 +59,17 @@
}); });
test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () { test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () {
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'external_id' => 'esp-1', 'external_id' => 'esp-1',
'policy_type' => 'windowsEnrollmentStatusPage', 'policy_type' => 'windowsEnrollmentStatusPage',
'display_name' => 'ESP A', 'display_name' => 'ESP A',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
PolicyVersion::create([ PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -113,17 +101,17 @@
}); });
test('policy detail renders normalized settings for platform restrictions (enrollment)', function () { test('policy detail renders normalized settings for platform restrictions (enrollment)', function () {
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'external_id' => 'enroll-restrict-1', 'external_id' => 'enroll-restrict-1',
'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'display_name' => 'Restriction A', 'display_name' => 'Restriction A',
'platform' => 'all', 'platform' => 'all',
]); ]);
PolicyVersion::create([ PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id, 'tenant_id' => $this->tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,

View File

@ -22,6 +22,8 @@
test('entra group sync runs are listed for the active tenant', function () { test('entra group sync runs are listed for the active tenant', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create();
EntraGroupSyncRun::query()->create([ EntraGroupSyncRun::query()->create([
@ -38,12 +40,6 @@
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant)) ->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant))
->assertOk() ->assertOk()
@ -53,7 +49,9 @@
test('entra group sync run view is forbidden cross-tenant (403)', function () { test('entra group sync run view is forbidden cross-tenant (403)', function () {
$tenantA = Tenant::factory()->create(); $tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create(); $tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
$runB = EntraGroupSyncRun::query()->create([ $runB = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenantB->getKey(), 'tenant_id' => $tenantB->getKey(),
@ -63,10 +61,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))

View File

@ -2,7 +2,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
@ -90,11 +89,10 @@ public function request(string $method, string $path, array $options = []): Grap
$client = new GroupPolicyHydrationGraphClient; $client = new GroupPolicyHydrationGraphClient;
app()->instance(GraphClientInterface::class, $client); app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-gpo-hydration', 'tenant_id' => 'tenant-gpo-hydration',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
@ -102,8 +100,8 @@ public function request(string $method, string $path, array $options = []): Grap
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'external_id' => 'gpo-hydrate', 'external_id' => 'gpo-hydrate',
'policy_type' => 'groupPolicyConfiguration', 'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates Alpha', 'display_name' => 'Admin Templates Alpha',
@ -132,10 +130,7 @@ public function request(string $method, string $path, array $options = []): Grap
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id], metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
); );
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)

View File

@ -398,16 +398,14 @@
}); });
test('tenant can be archived and hidden from default lists', function () { test('tenant can be archived and hidden from default lists', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-4', 'tenant_id' => 'tenant-4',
'name' => 'Tenant 4', 'name' => 'Tenant 4',
'status' => 'active',
]); ]);
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
@ -436,64 +434,86 @@
}); });
test('tenant table archive filter toggles active and archived tenants', function () { test('tenant table archive filter toggles active and archived tenants', function () {
$active = Tenant::create([ $active = Tenant::factory()->create([
'tenant_id' => 'tenant-active', 'tenant_id' => 'tenant-active',
'name' => 'Active Tenant', 'name' => 'Active Tenant',
'status' => 'active',
]); ]);
$archived = Tenant::create([ [$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
$this->actingAs($user);
$archived = Tenant::factory()->create([
'tenant_id' => 'tenant-archived', 'tenant_id' => 'tenant-archived',
'name' => 'Archived Tenant', 'name' => 'Archived Tenant',
'status' => 'active',
'workspace_id' => $active->workspace_id,
]); ]);
$archived->delete(); $archived->delete();
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$active->getKey() => ['role' => 'owner'],
$archived->getKey() => ['role' => 'owner'], $archived->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($active, true); Filament::setTenant($active, true);
$component = Livewire::test(ListTenants::class) $this->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id,
]);
session([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $active->workspace_id,
]);
$component = Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('trashed', true)
->assertSee($active->name) ->assertSee($active->name)
->assertSee($archived->name); ->assertSee($archived->name);
$component $component
->set('tableFilters.trashed.value', null) ->filterTable('trashed', null)
->assertSee($active->name) ->assertSee($active->name)
->assertDontSee($archived->name); ->assertDontSee($archived->name);
$component $component
->set('tableFilters.trashed.value', 0) ->filterTable('trashed', false)
->assertSee($archived->name) ->assertSee($archived->name)
->assertDontSee($active->name); ->assertDontSee($active->name);
}); });
test('archived tenant can be restored from the table', function () { test('archived tenant can be restored from the table', function () {
$tenant = Tenant::create([ $contextTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant',
'status' => 'active',
]);
[$user, $contextTenant] = createUserWithTenant(tenant: $contextTenant, role: 'owner');
$this->actingAs($user);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-restore', 'tenant_id' => 'tenant-restore',
'name' => 'Restore Tenant', 'name' => 'Restore Tenant',
'status' => 'active',
'workspace_id' => $contextTenant->workspace_id,
]); ]);
$tenant->delete(); $tenant->delete();
$contextTenant = Tenant::create([
'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant',
]);
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
$contextTenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($contextTenant, true); Filament::setTenant($contextTenant, true);
Livewire::test(ListTenants::class) $this->withSession([
->set('tableFilters.trashed.value', 1) \App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id,
]);
session([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $contextTenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('trashed', false)
->callTableAction('restore', $tenant); ->callTableAction('restore', $tenant);
$this->assertDatabaseHas('tenants', [ $this->assertDatabaseHas('tenants', [

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -18,6 +17,8 @@
test('inventory items are listed for the active tenant', function () { test('inventory items are listed for the active tenant', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create();
InventoryItem::factory()->create([ InventoryItem::factory()->create([
@ -36,12 +37,6 @@
'platform' => 'windows', 'platform' => 'windows',
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant)) ->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk() ->assertOk()

View File

@ -6,17 +6,12 @@
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory hub pages load for a tenant', function () { test('inventory hub pages load for a tenant', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\InventorySyncRunResource; use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -14,6 +13,8 @@
test('inventory sync runs are listed for the active tenant', function () { test('inventory sync runs are listed for the active tenant', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$otherTenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create();
InventorySyncRun::factory()->create([ InventorySyncRun::factory()->create([
@ -28,12 +29,6 @@
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => InventorySyncRun::STATUS_SUCCESS,
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
->assertOk() ->assertOk()

View File

@ -5,20 +5,19 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('malformed snapshot renders warning on policy and version detail', function () { test('malformed snapshot renders warning on policy and version detail', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -40,11 +39,6 @@
'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning 'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$policyResponse = $this->actingAs($user) $policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -6,7 +6,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
@ -49,13 +48,13 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
} }
}); });
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -99,11 +98,6 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'payload' => $snapshot, 'payload' => $snapshot,
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$detailResponse = $this->actingAs($user) $detailResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -30,8 +30,8 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'], $otherTenant->getKey() => ['role' => 'owner'],
]); ]);

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy detail shows normalized settings section', function () { test('policy detail shows normalized settings section', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create();
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -49,9 +42,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy settings standard view renders array values without crashing', function () { test('policy settings standard view renders array values without crashing', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create();
'tenant_id' => 'tenant-arrays',
'name' => 'Tenant Arrays',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -48,9 +41,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');

View File

@ -11,14 +11,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy version detail renders tabs and scroll-safe blocks', function () { test('policy version detail renders tabs and scroll-safe blocks', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create();
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -58,9 +51,7 @@
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));

View File

@ -4,33 +4,30 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy version view shows scope tags even when assignments are missing', function () { test('policy version view shows scope tags even when assignments are missing', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
$tenant->makeCurrent(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1', 'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
$version = PolicyVersion::create([ $version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -46,11 +43,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));

View File

@ -4,33 +4,30 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy version detail shows raw and normalized settings', function () { test('policy version detail shows raw and normalized settings', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
$tenant->makeCurrent(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1', 'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
$version = PolicyVersion::create([ $version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -44,11 +41,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
@ -61,26 +53,25 @@
}); });
test('policy version detail shows enrollment notification template settings', function () { test('policy version detail shows enrollment notification template settings', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-enrollment-notify', 'tenant_id' => 'tenant-enrollment-notify',
'name' => 'Tenant Enrollment Notify', 'name' => 'Tenant Enrollment Notify',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
$tenant->makeCurrent(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::create([ $policy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'external_id' => 'enroll-notify-1', 'external_id' => 'enroll-notify-1',
'policy_type' => 'deviceEnrollmentNotificationConfiguration', 'policy_type' => 'deviceEnrollmentNotificationConfiguration',
'display_name' => 'Enrollment Notifications', 'display_name' => 'Enrollment Notifications',
'platform' => 'all', 'platform' => 'all',
]); ]);
$version = PolicyVersion::create([ $version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->id, 'policy_id' => $policy->getKey(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
@ -134,11 +125,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');

View File

@ -10,13 +10,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('policy versions render with timeline data', function () { test('policy versions render with timeline data', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create();
'tenant_id' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@ -31,9 +25,7 @@
$service->captureVersion($policy, ['value' => 2], 'tester'); $service->captureVersion($policy, ['value' => 2], 'tester');
$user = User::factory()->create(); $user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user) $this->actingAs($user)
->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant))) ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))

View File

@ -5,20 +5,19 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\SettingsCatalogDefinition; use App\Models\SettingsCatalogDefinition;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('shows Settings tab for Settings Catalog policy', function () { it('shows Settings tab for Settings Catalog policy', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -70,11 +69,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -87,13 +81,13 @@
}); });
it('shows display names instead of definition IDs', function () { it('shows display names instead of definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -132,11 +126,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -146,13 +135,13 @@
})->skip('Manual UI verification required'); })->skip('Manual UI verification required');
it('shows fallback prettified labels when definitions not cached', function () { it('shows fallback prettified labels when definitions not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -186,11 +175,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -200,13 +184,13 @@
})->skip('Manual UI verification required'); })->skip('Manual UI verification required');
it('shows tabbed layout for non-Settings Catalog policies', function () { it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'status' => 'active',
'is_current' => true,
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -233,11 +217,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -249,11 +228,13 @@
// T034: Test display names shown (not definition IDs) // T034: Test display names shown (not definition IDs)
it('displays setting display names instead of raw definition IDs', function () { it('displays setting display names instead of raw definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
SettingsCatalogDefinition::create([ SettingsCatalogDefinition::create([
@ -292,10 +273,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -306,11 +283,13 @@
// T035: Test values formatted correctly // T035: Test values formatted correctly
it('formats setting values correctly based on type', function () { it('formats setting values correctly based on type', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
SettingsCatalogDefinition::create([ SettingsCatalogDefinition::create([
@ -370,10 +349,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -383,11 +358,13 @@
// T036: Test search/filter functionality // T036: Test search/filter functionality
it('search filters settings in real-time', function () { it('search filters settings in real-time', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
SettingsCatalogDefinition::create([ SettingsCatalogDefinition::create([
@ -436,10 +413,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
@ -449,11 +422,13 @@
// T037: Test graceful degradation for missing definitions // T037: Test graceful degradation for missing definitions
it('shows prettified fallback labels when definitions are not cached', function () { it('shows prettified fallback labels when definitions are not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'local-tenant', 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -485,10 +460,6 @@
], ],
]); ]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -3,7 +3,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -13,10 +12,7 @@
putenv('INTUNE_TENANT_ID='); putenv('INTUNE_TENANT_ID=');
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -80,10 +76,7 @@
]); ]);
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -148,10 +141,7 @@
]); ]);
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -2,7 +2,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
@ -104,10 +103,7 @@ public function request(string $method, string $path, array $options = []): Grap
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id], metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
); );
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
@ -147,10 +143,7 @@ public function request(string $method, string $path, array $options = []): Grap
$versions = app(VersionService::class); $versions = app(VersionService::class);
$versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com'); $versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com');
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)

View File

@ -5,7 +5,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -89,10 +88,7 @@
], ],
]); ]);
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$policyResponse = $this->actingAs($user) $policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));

View File

@ -3,7 +3,6 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
@ -110,10 +109,7 @@ public function request(string $method, string $path, array $options = []): Grap
], ],
]); ]);
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this $response = $this
->actingAs($user) ->actingAs($user)

View File

@ -4,7 +4,6 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
@ -146,10 +145,7 @@ public function request(string $method, string $path, array $options = []): Grap
'payload' => $payload, 'payload' => $payload,
]); ]);
$user = User::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
$service = app(RestoreService::class); $service = app(RestoreService::class);

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