Compare commits

...

5 Commits

Author SHA1 Message Date
3f09fd50f6 feat(spec-080): workspace-managed tenant administration migration (#97)
Implements Spec 080: split Filament into workspace-managed `/admin/*` (manage) vs tenant operations `/admin/t/{tenant}/*` (operate).

Highlights:
- Adds tenant operations panel (`tenant`) at `/admin/t` with tenancy by `Tenant.external_id`
- Keeps management resources in workspace panel (`admin`) under `/admin/tenants/*`
- Moves Provider Connections to workspace-managed routes: `/admin/tenants/{tenant}/provider-connections`
- Adds discoverability CTA on tenant view (Actions → Provider connections)
- Adds/updates Pest regression tests for routing boundaries, 404/403 RBAC-UX semantics, and global search isolation
- Includes full Spec Kit artifacts under `specs/080-workspace-managed-tenant-admin/`

Validation:
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php`

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #97
2026-02-07 19:45:13 +00:00
ff671d8d4a fix(spec-079): allow non-UUID ids in inventory_links (#96)
## Why
Some Microsoft Graph / Intune identifiers are not UUIDs (e.g. scope tag id "0"). With `inventory_links.source_id` / `target_id` typed as `uuid`, PostgreSQL fails when inventory dependency extraction tries to persist those edges.

## What
- PostgreSQL migration changes `inventory_links.source_id` and `inventory_links.target_id` to `text`.
- Regression test ensures a non-UUID id ("0") can be persisted; on pgsql it also asserts the columns are `text`.

## Notes
- UUID identifiers continue to work (stored as strings).
- No UI/Filament changes.

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #96
2026-02-07 09:18:00 +00:00
d56ba85755 Spec 078: Operations tenantless canonical detail (#95)
Implements Spec 078 operations tenantless canonical migration.

Highlights:
- Canonical run detail at `/admin/operations/{run}` renders with standard Filament chrome + sidebar and reuses `OperationRunResource::infolist()` (schema-based, Filament v5).
- Legacy tenant-scoped resource pages removed; legacy URLs return 404 as required.
- Added full spec test pack under `tests/Feature/078/` and updated existing tests.
- Added safe refresh/header actions wiring and KPI header guard when tenant context is null.

Validation:
- `vendor/bin/sail artisan test --compact tests/Feature/078/` (pass)
- `vendor/bin/sail bin pint --dirty` (pass)

Notes:
- Livewire v4+ compliant (Filament v5).
- Panel providers remain registered in `bootstrap/providers.php` (Laravel 11+ standard).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #95
2026-02-07 09:07:26 +00:00
fb1046c97a Spec 077: Workspace Global Mode + context bar redundancy cleanup (#94)
Implements Spec 077 refinements: workspace Global Mode and navigation/context-bar redundancy cleanup.

Summary
- Global Mode: `/admin/workspaces` is workspace-optional (lists only member workspaces); explicit allowlist in `EnsureWorkspaceSelected`.
- Navigation cleanup: workspace switching is topbar-only; no sidebar “Switch workspace”; removes redundant “Manage workspaces” entry from context-bar.
- Context bar: when no workspace selected, tenant picker is disabled with guidance; on tenant-scoped routes `/admin/t/{tenant}/…` the tenant indicator is read-only (Filament tenant menu remains primary).
- Authorization: workspace creation is policy-driven (`WorkspacePolicy::create()`), enforced in `ChooseWorkspace` via Gate.

Safety / Compliance
- Livewire v4.0+ compliant (Filament v5).
- Panel provider registration remains in `bootstrap/providers.php` (no changes required).
- Global search: no new globally searchable resources added; no behavior changes introduced.
- Destructive actions: none added/changed.
- Assets: no new assets registered; deploy process unchanged (if assets are registered elsewhere, ensure `php artisan filament:assets` runs in deploy as usual).

Tests
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`

Spec artifacts
- `specs/077-workspace-nav-monitoring-hub/{spec,plan,tasks}.md`
- `specs/077-workspace-nav-monitoring-hub/contracts/routes.md`

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #94
2026-02-06 22:14:53 +00:00
05a604cfb6 Spec 076: Tenant Required Permissions (enterprise remediation UX) (#92)
Implements Spec 076 enterprise remediation UX for tenant required permissions.

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

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

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

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #92
2026-02-05 22:08:51 +00:00
144 changed files with 11043 additions and 809 deletions

View File

@ -7,9 +7,12 @@ Thumbs.db
.env
.env.*
*.log
*.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Dockerfile*
.dockerignore
*.tmp
*.swp
public/build/

View File

@ -16,6 +16,10 @@ ## Active Technologies
- 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 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -35,9 +39,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->

2
.gitignore vendored
View File

@ -6,6 +6,7 @@
.env.production
.phpactor.json
.phpunit.result.cache
*.cache
/.fleet
/.idea
/.nova
@ -24,6 +25,7 @@ coverage/
/storage/pail
/storage/framework
/storage/logs
/storage/debugbar
/vendor
/bootstrap/cache
Homestead.json

View File

@ -7,6 +7,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -69,7 +70,9 @@ public function selectTenant(int $tenantId): void
$this->persistLastTenant($user, $tenant);
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void

View File

@ -8,11 +8,13 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class ChooseWorkspace extends Page
{
@ -37,6 +39,12 @@ protected function getHeaderActions(): array
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->visible(function (): bool {
$user = auth()->user();
return $user instanceof User
&& Gate::forUser($user)->check('create', Workspace::class);
})
->form([
TextInput::make('name')
->required()
@ -100,7 +108,9 @@ public function selectWorkspace(int $workspaceId): void
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
}
/**
@ -114,6 +124,8 @@ public function createWorkspace(array $data): void
abort(403);
}
Gate::forUser($user)->authorize('create', Workspace::class);
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
@ -132,7 +144,9 @@ public function createWorkspace(array $data): void
->success()
->send();
$this->redirect($this->redirectAfterWorkspaceSelected($user));
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
}
private function redirectAfterWorkspaceSelected(User $user): string
@ -163,7 +177,7 @@ private function redirectAfterWorkspaceSelected(User $user): string
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(tenant: $tenant);
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class Alerts extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alerts';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static ?string $slug = 'alerts';
protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts';
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class AuditLog extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Audit Log';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $slug = 'audit-log';
protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log';
}

View File

@ -1,22 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
@ -26,6 +25,8 @@ class Operations extends Page implements HasForms, HasTable
use InteractsWithForms;
use InteractsWithTable;
public string $activeTab = 'all';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
@ -37,89 +38,62 @@ class Operations extends Page implements HasForms, HasTable
// Must be non-static
protected string $view = 'filament.pages.monitoring.operations';
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getHeaderWidgets(): array
{
return [
OperationsKpiHeader::class,
];
}
public function updatedActiveTab(): void
{
$this->resetPage();
}
public function table(Table $table): Table
{
return $table
->query(
OperationRun::query()
->where('tenant_id', Filament::getTenant()->id)
->latest('created_at')
)
->columns([
TextColumn::make('type')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
return OperationRunResource::table($table)
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Started'),
TextColumn::make('duration')
->getStateUsing(function (OperationRun $record) {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '-';
}),
])
->filters([
SelectFilter::make('outcome')
->options([
'succeeded' => 'Succeeded',
'partially_succeeded' => 'Partially Succeeded',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
'pending' => 'Pending',
]),
SelectFilter::make('type')
->options(
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
->distinct()
->pluck('type', 'type')
->toArray()
),
Filter::make('created_at')
->form([
DatePicker::make('created_from'),
DatePicker::make('created_until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
$query = OperationRun::query()
->with('user')
->latest('id')
->when(
$data['created_from'],
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
$workspaceId,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$data['created_until'],
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}),
])
->actions([
// View action handled by opening a modal or side-peek
]);
return $this->applyActiveTab($query);
});
}
private function applyActiveTab(Builder $query): Builder
{
return match ($this->activeTab) {
'active' => $query->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
]),
'succeeded' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value),
'partial' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
'failed' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value),
default => $query,
};
}
}

View File

@ -4,16 +4,22 @@
namespace App\Filament\Pages\Operations;
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class TenantlessOperationRunViewer extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
@ -24,18 +30,53 @@ class TenantlessOperationRunViewer extends Page
public OperationRun $run;
public bool $opsUxIsTabHidden = false;
/**
* @return array<Action>
* @return array<Action|ActionGroup>
*/
protected function getHeaderActions(): array
{
return [
$actions = [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->color('gray')
->url(fn (): string => url()->current()),
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index')),
];
if (! isset($this->run)) {
return $actions;
}
$user = auth()->user();
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
$tenant = null;
}
$related = OperationRunLinks::related($this->run, $tenant);
$relatedActions = [];
foreach ($related as $label => $url) {
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
->label((string) $label)
->url((string) $url)
->openUrlInNewTab();
}
if ($relatedActions !== []) {
$actions[] = ActionGroup::make($relatedActions)
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray');
}
return $actions;
}
public function mount(OperationRun $run): void
@ -63,4 +104,23 @@ public function mount(OperationRun $run): void
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
}
public function infolist(Schema $schema): Schema
{
return OperationRunResource::infolist($schema);
}
public function defaultInfolist(Schema $schema): Schema
{
return $schema
->record($this->run)
->columns(2);
}
public function content(Schema $schema): Schema
{
return $schema->schema([
EmbeddedSchema::make('infolist'),
]);
}
}

View File

@ -11,9 +11,18 @@
use Filament\Pages\Dashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Database\Eloquent\Model;
class TenantDashboard extends Dashboard
{
/**
* @param array<mixed> $parameters
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/

View File

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
class TenantRequiredPermissions extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
protected static ?string $title = 'Required permissions';
protected string $view = 'filament.pages.tenant-required-permissions';
public string $status = 'missing';
public string $type = 'all';
/**
* @var array<int, string>
*/
public array $features = [];
public string $search = '';
/**
* @var array<string, mixed>
*/
public array $viewModel = [];
public static function canAccess(): bool
{
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
}
public function currentTenant(): ?Tenant
{
return static::resolveScopedTenant();
}
public function mount(): void
{
$queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => request()->query('status', $this->status),
'type' => request()->query('type', $this->type),
'features' => is_array($queryFeatures) ? $queryFeatures : [],
'search' => request()->query('search', $this->search),
]);
$this->status = $state['status'];
$this->type = $state['type'];
$this->features = $state['features'];
$this->search = $state['search'];
$this->refreshViewModel();
}
public function updatedStatus(): void
{
$this->refreshViewModel();
}
public function updatedType(): void
{
$this->refreshViewModel();
}
public function updatedFeatures(): void
{
$this->refreshViewModel();
}
public function updatedSearch(): void
{
$this->refreshViewModel();
}
public function applyFeatureFilter(string $feature): void
{
$feature = trim($feature);
if ($feature === '') {
return;
}
if (in_array($feature, $this->features, true)) {
$this->features = array_values(array_filter(
$this->features,
static fn (string $value): bool => $value !== $feature,
));
} else {
$this->features[] = $feature;
}
$this->features = array_values(array_unique($this->features));
$this->refreshViewModel();
}
public function clearFeatureFilter(): void
{
$this->features = [];
$this->refreshViewModel();
}
public function resetFilters(): void
{
$this->status = 'missing';
$this->type = 'all';
$this->features = [];
$this->search = '';
$this->refreshViewModel();
}
private function refreshViewModel(): void
{
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
return;
}
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
$this->viewModel = $builder->build($tenant, [
'status' => $this->status,
'type' => $this->type,
'features' => $this->features,
'search' => $this->search,
]);
$filters = $this->viewModel['filters'] ?? null;
if (is_array($filters)) {
$this->status = (string) ($filters['status'] ?? $this->status);
$this->type = (string) ($filters['type'] ?? $this->type);
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
$this->search = (string) ($filters['search'] ?? $this->search);
}
}
public function reRunVerificationUrl(): ?string
{
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
return null;
}
$connectionId = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('is_default')
->orderByDesc('id')
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
{
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
if (is_string($routeTenant) && $routeTenant !== '') {
return Tenant::query()
->where('external_id', $routeTenant)
->first();
}
return Tenant::current();
}
}

View File

@ -20,6 +20,7 @@
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\TenantMembershipManager;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationRegistry;
@ -220,7 +221,57 @@ public function content(Schema $schema): Schema
->label('Provider connection')
->required(fn (Get $get): bool => $get('connection_mode') === 'existing')
->options(fn (): array => $this->providerConnectionOptions())
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'),
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing')
->hintActions([
Action::make('edit_selected_connection')
->label('Edit selected connection')
->icon('heroicon-m-pencil-square')
->color('gray')
->slideOver()
->modalHeading('Edit provider connection')
->modalDescription('Changes apply to this workspace connection.')
->modalSubmitActionLabel('Save changes')
->closeModalByClickingAway(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'
&& is_numeric($get('provider_connection_id')))
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE)
? null
: 'You don\'t have permission to edit connections.')
->fillForm(function (Get $get): array {
$recordId = $get('provider_connection_id');
if (! is_numeric($recordId)) {
return [];
}
return $this->inlineEditSelectedConnectionFill((int) $recordId);
})
->form([
TextInput::make('display_name')
->label('Connection name')
->required()
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Directory (tenant) ID')
->disabled()
->dehydrated(false),
TextInput::make('client_id')
->label('App (client) ID')
->required()
->maxLength(255)
->helperText('The client secret is not editable here.'),
])
->action(function (array $data, Get $get): void {
$recordId = $get('provider_connection_id');
if (! is_numeric($recordId)) {
abort(404);
}
$this->updateSelectedProviderConnectionInline((int) $recordId, $data);
}),
]),
TextInput::make('new_connection.display_name')
->label('Display name')
@ -287,7 +338,11 @@ public function content(Schema $schema): Schema
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
->badge()
->color(fn (): string => $this->verificationStatusColor()),
Text::make('Verification is in progress. Use “Refresh” to see the latest stored status.')
Text::make('Connection updated — re-run verification to refresh results.')
->visible(fn (): bool => $this->connectionRecentlyUpdated()),
Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()),
Text::make('Verification is in progress. Use “Refresh results” to see the latest stored status.')
->visible(fn (): bool => $this->verificationStatus() === 'in_progress'),
SchemaActions::make([
Action::make('wizardStartVerification')
@ -566,6 +621,10 @@ private function verificationStatus(): string
return 'not_started';
}
if (! $this->verificationRunMatchesSelectedConnection($run)) {
return 'needs_attention';
}
if ($run->status !== OperationRunStatus::Completed->value) {
return 'in_progress';
}
@ -607,6 +666,14 @@ private function verificationStatus(): string
return 'needs_attention';
}
private function verificationRunIsActive(): bool
{
$run = $this->verificationRun();
return $run instanceof OperationRun
&& $run->status !== OperationRunStatus::Completed->value;
}
private function verificationStatusColor(): string
{
return BadgeCatalog::spec(
@ -714,6 +781,7 @@ private function verificationReportViewData(): array
$targetScope = is_array($targetScope) ? $targetScope : [];
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
$verificationReport = VerificationReportViewer::report($run);
return [
'run' => [
@ -1147,12 +1215,18 @@ public function selectProviderConnection(int $providerConnectionId): void
abort(404);
}
$previousProviderConnectionId = $this->selectedProviderConnectionId;
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
state: array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]);
]),
previousProviderConnectionId: $previousProviderConnectionId,
newProviderConnectionId: (int) $connection->getKey(),
);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
@ -1230,9 +1304,18 @@ public function createProviderConnection(array $data): void
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
$previousProviderConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
$previousProviderConnectionId = is_int($previousProviderConnectionId)
? $previousProviderConnectionId
: (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null);
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
state: array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]);
]),
previousProviderConnectionId: $previousProviderConnectionId,
newProviderConnectionId: (int) $connection->getKey(),
);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
@ -1306,6 +1389,7 @@ public function startVerification(): void
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $result->run->getKey(),
'connection_recently_updated' => false,
]);
$this->onboardingSession->current_step = 'verify';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
@ -1752,7 +1836,7 @@ public function completeOnboarding(): void
resourceId: (string) $tenant->getKey(),
);
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
private function verificationRun(): ?OperationRun
@ -1785,7 +1869,237 @@ private function verificationHasSucceeded(): bool
return false;
}
return $run->status === OperationRunStatus::Completed->value && $run->outcome === OperationRunOutcome::Succeeded->value;
return $run->status === OperationRunStatus::Completed->value
&& $run->outcome === OperationRunOutcome::Succeeded->value
&& $this->verificationRunMatchesSelectedConnection($run);
}
private function verificationRunIsStaleForSelectedConnection(): bool
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
return ! $this->verificationRunMatchesSelectedConnection($run);
}
private function verificationRunMatchesSelectedConnection(OperationRun $run): bool
{
$selectedProviderConnectionId = $this->selectedProviderConnectionId;
if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) {
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
$selectedProviderConnectionId = is_int($candidate)
? $candidate
: (is_numeric($candidate) ? (int) $candidate : null);
}
if ($selectedProviderConnectionId === null) {
return false;
}
$context = is_array($run->context ?? null) ? $run->context : [];
$runProviderConnectionId = $context['provider_connection_id'] ?? null;
$runProviderConnectionId = is_int($runProviderConnectionId)
? $runProviderConnectionId
: (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null);
if ($runProviderConnectionId === null) {
return false;
}
return $runProviderConnectionId === $selectedProviderConnectionId;
}
/**
* @param array<string, mixed> $state
* @return array<string, mixed>
*/
private function resetDependentOnboardingStateOnConnectionChange(array $state, ?int $previousProviderConnectionId, int $newProviderConnectionId): array
{
if ($previousProviderConnectionId === null) {
return $state;
}
if ($previousProviderConnectionId === $newProviderConnectionId) {
return $state;
}
unset(
$state['verification_operation_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
);
$state['connection_recently_updated'] = true;
return $state;
}
private function connectionRecentlyUpdated(): bool
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return false;
}
return (bool) ($this->onboardingSession->state['connection_recently_updated'] ?? false);
}
/**
* @return array{display_name: string, entra_tenant_id: string, client_id: string}
*/
private function inlineEditSelectedConnectionFill(int $providerConnectionId): array
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$connection = ProviderConnection::query()
->with('credential')
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
abort(404);
}
$payload = $connection->credential?->payload;
$payload = is_array($payload) ? $payload : [];
return [
'display_name' => (string) $connection->display_name,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'client_id' => (string) ($payload['client_id'] ?? ''),
];
}
/**
* @param array{display_name?: mixed, client_id?: mixed} $data
*/
public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$connection = ProviderConnection::query()
->with('credential')
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
abort(404);
}
$displayName = trim((string) ($data['display_name'] ?? ''));
$clientId = trim((string) ($data['client_id'] ?? ''));
if ($displayName === '') {
throw ValidationException::withMessages([
'display_name' => 'Connection name is required.',
]);
}
if ($clientId === '') {
throw ValidationException::withMessages([
'client_id' => 'App (client) ID is required.',
]);
}
$existingPayload = $connection->credential?->payload;
$existingPayload = is_array($existingPayload) ? $existingPayload : [];
$existingClientId = trim((string) ($existingPayload['client_id'] ?? ''));
$changedFields = [];
if ($displayName !== (string) $connection->display_name) {
$changedFields[] = 'display_name';
}
if ($clientId !== $existingClientId) {
$changedFields[] = 'client_id';
}
DB::transaction(function () use ($connection, $displayName, $clientId): void {
$connection->forceFill([
'display_name' => $displayName,
])->save();
app(CredentialManager::class)->updateClientIdPreservingSecret(
connection: $connection,
clientId: $clientId,
);
});
if ($changedFields !== []) {
app(AuditLogger::class)->log(
tenant: $this->managedTenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'fields' => $changedFields,
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
],
],
actorId: (int) $user->getKey(),
actorEmail: (string) $user->email,
actorName: (string) $user->name,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
unset(
$state['verification_operation_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
);
$state['connection_recently_updated'] = true;
$this->onboardingSession->state = array_merge($state, [
'provider_connection_id' => (int) $connection->getKey(),
]);
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
Notification::make()
->title('Connection updated')
->success()
->send();
$this->initializeWizardData();
}
/**

View File

@ -74,6 +74,6 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
@ -17,8 +16,10 @@
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
@ -38,6 +39,8 @@ class OperationRunResource extends Resource
protected static ?string $slug = 'operations';
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
@ -46,12 +49,13 @@ class OperationRunResource extends Resource
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
return parent::getEloquentQuery()
->with('user')
->latest('id')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
}
public static function form(Schema $schema): Schema
@ -86,6 +90,11 @@ public static function infolist(Schema $schema): Schema
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
->columnSpanFull(),
TextEntry::make('target_scope_empty_state')
->label('Target')
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
->columnSpanFull(),
TextEntry::make('elapsed')
->label('Elapsed')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
@ -156,7 +165,7 @@ public static function infolist(Schema $schema): Schema
$previousRunUrl = null;
if ($changeIndicator !== null) {
$tenant = Tenant::current();
$tenant = Filament::getTenant();
$previousRunUrl = $tenant instanceof Tenant
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
@ -272,16 +281,47 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
])
->filters([
Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$user = auth()->user();
if (! $user instanceof User) {
return [];
}
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->getFilamentName(),
])
->all();
})
->default(function (): ?string {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return null;
}
return (string) $tenant->getKey();
})
->searchable(),
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if (! $tenantId) {
if ($workspaceId === null) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->where('workspace_id', (int) $workspaceId)
->select('type')
->distinct()
->orderBy('type')
@ -299,14 +339,20 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if (! $tenantId) {
if ($workspaceId === null) {
return [];
}
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
? (int) $tenant->getKey()
: null;
return OperationRun::query()
->where('tenant_id', $tenantId)
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
@ -342,17 +388,16 @@ public static function table(Table $table): Table
}),
])
->actions([
Actions\ViewAction::make(),
Actions\ViewAction::make()
->label('View run')
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListOperationRuns::route('/'),
'view' => Pages\ViewOperationRun::route('/{record}'),
];
return [];
}
private static function targetScopeDisplay(OperationRun $record): ?string

View File

@ -1,64 +0,0 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
class ListOperationRuns extends ListRecords
{
protected static string $resource = OperationRunResource::class;
protected function getHeaderWidgets(): array
{
return [
OperationsKpiHeader::class,
];
}
/**
* @return array<string, Tab>
*/
public function getTabs(): array
{
return [
'all' => Tab::make(),
'active' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])),
'succeeded' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value)),
'partial' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
'failed' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)),
];
}
protected function getTablePollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Str;
class ViewOperationRun extends ViewRecord
{
protected static string $resource = OperationRunResource::class;
public bool $opsUxIsTabHidden = false;
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return [];
}
/** @var OperationRun $run */
$run = $this->getRecord();
$related = OperationRunLinks::related($run, $tenant);
$actions = [];
foreach ($related as $label => $url) {
$actions[] = Actions\Action::make(Str::slug($label, '_'))
->label($label)
->url($url)
->openUrlInNewTab();
}
if (empty($actions)) {
return [];
}
return [
Actions\ActionGroup::make($actions)
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray'),
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
@ -32,16 +31,21 @@
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class ProviderConnectionResource extends Resource
{
use ScopesGlobalSearchToTenant;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $model = ProviderConnection::class;
protected static ?string $slug = 'tenants/{tenant}/provider-connections';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static string|UnitEnum|null $navigationGroup = 'Providers';
@ -52,7 +56,7 @@ class ProviderConnectionResource extends Resource
protected static function hasTenantCapability(string $capability): bool
{
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -66,6 +70,23 @@ protected static function hasTenantCapability(string $capability): bool
&& $resolver->can($user, $tenant, $capability);
}
protected static function resolveScopedTenant(): ?Tenant
{
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
if (is_string($routeTenant) && $routeTenant !== '') {
return Tenant::query()
->where('external_id', $routeTenant)
->first();
}
return Tenant::current();
}
public static function form(Schema $schema): Schema
{
return $schema
@ -101,7 +122,7 @@ public static function table(Table $table): Table
return $table
->modifyQueryUsing(function (Builder $query): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = Tenant::current()?->getKey();
$tenantId = static::resolveScopedTenant()?->getKey();
if ($workspaceId === null) {
return $query->whereRaw('1 = 0');
@ -184,7 +205,7 @@ public static function table(Table $table): Table
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -254,7 +275,7 @@ public static function table(Table $table): Table
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -331,7 +352,7 @@ public static function table(Table $table): Table
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -408,7 +429,7 @@ public static function table(Table $table): Table
->color('primary')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
return;
@ -466,7 +487,7 @@ public static function table(Table $table): Table
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
return;
@ -516,7 +537,7 @@ public static function table(Table $table): Table
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
return;
@ -587,7 +608,7 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant) {
return;
@ -642,7 +663,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = Tenant::current()?->getKey();
$tenantId = static::resolveScopedTenant()?->getKey();
$query = parent::getEloquentQuery();
@ -664,4 +685,20 @@ public static function getPages(): array
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
];
}
/**
* @param array<mixed> $parameters
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
if (! array_key_exists('tenant', $parameters)) {
$resolvedTenant = static::resolveScopedTenant();
if ($resolvedTenant instanceof Tenant) {
$parameters['tenant'] = $resolvedTenant->external_id;
}
}
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
}
}

View File

@ -17,7 +17,11 @@ class CreateProviderConnection extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
@ -33,7 +37,12 @@ protected function mutateFormDataBeforeCreate(array $data): array
protected function afterCreate(): void
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$record = $this->getRecord();
$user = auth()->user();
@ -72,4 +81,21 @@ protected function afterCreate(): void
->success()
->send();
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -42,7 +42,7 @@ protected function mutateFormDataBeforeSave(array $data): array
protected function afterSave(): void
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$record = $this->getRecord();
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
@ -109,7 +109,7 @@ protected function afterSave(): void
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
return [
Actions\DeleteAction::make()
@ -128,7 +128,7 @@ protected function getHeaderActions(): array
->where('context->provider_connection_id', (int) $record->getKey())
->exists())
->url(function (ProviderConnection $record): ?string {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
return null;
@ -159,7 +159,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-check-badge')
->color('success')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
return $tenant instanceof Tenant
@ -168,7 +168,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -256,7 +256,7 @@ protected function getHeaderActions(): array
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
@ -314,7 +314,7 @@ protected function getHeaderActions(): array
->where('provider', $record->provider)
->count() > 1)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
@ -361,7 +361,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-arrow-path')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
return $tenant instanceof Tenant
@ -370,7 +370,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -455,7 +455,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-shield-check')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
return $tenant instanceof Tenant
@ -464,7 +464,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -550,7 +550,7 @@ protected function getHeaderActions(): array
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
@ -622,7 +622,7 @@ protected function getHeaderActions(): array
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
@ -676,7 +676,7 @@ protected function getHeaderActions(): array
protected function getFormActions(): array
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
@ -699,7 +699,7 @@ protected function getFormActions(): array
protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = Tenant::current();
$tenant = $this->currentTenant();
$user = auth()->user();
@ -719,4 +719,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model
return parent::handleRecordUpdate($record, $data);
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -58,8 +58,14 @@ class TenantResource extends Resource
// ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $recordRouteKeyName = 'external_id';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
@ -286,7 +292,7 @@ public static function table(Table $table): Table
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
@ -405,13 +411,13 @@ public static function table(Table $table): Table
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
@ -801,6 +807,7 @@ public static function getPages(): array
'create' => Pages\CreateTenant::route('/create'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),
];
}
@ -942,7 +949,6 @@ public static function rbacAction(): Actions\Action
->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $record->external_id,
'record' => $record,
]),
])),
@ -1082,7 +1088,6 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));
@ -1272,7 +1277,6 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));

View File

@ -0,0 +1,8 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
class ManageTenantMemberships extends ViewTenant
{
protected static ?string $title = 'Tenant memberships';
}

View File

@ -2,7 +2,9 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
@ -23,6 +25,7 @@ protected function getHeaderWidgets(): array
{
return [
TenantArchivedBanner::class,
RecentOperationsSummary::class,
];
}
@ -30,6 +33,14 @@ protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('provider_connections')
->label('Provider connections')
->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin'))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Models\User;
use App\Models\Workspace;
use BackedEnum;
use Filament\Actions;
@ -11,6 +12,7 @@
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class WorkspaceResource extends Resource
@ -25,10 +27,31 @@ class WorkspaceResource extends Resource
protected static bool $shouldRegisterNavigation = false;
protected static ?string $breadcrumb = 'Manage workspaces';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if (! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
return $query
->whereNull('archived_at')
->whereIn('id', function ($subQuery) use ($user): void {
$subQuery->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
});
}
public static function form(Schema $schema): Schema
{
return $schema

View File

@ -5,7 +5,6 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\OperationRunResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -81,10 +80,10 @@ protected function getStats(): array
->url(FindingResource::getUrl('index', tenant: $tenant)),
Stat::make('Active operations', $activeRuns)
->color($activeRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
->url(route('admin.operations.index')),
Stat::make('Inventory active', $inventoryActiveRuns)
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
->url(route('admin.operations.index')),
];
}
}

View File

@ -40,12 +40,7 @@ protected function getStats(): array
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total Runs (30 days)', 0),
Stat::make('Active Runs', 0),
Stat::make('Failed/Partial (7 days)', 0),
Stat::make('Avg Duration (7 days)', '—'),
];
return [];
}
$tenantId = (int) $tenant->getKey();

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\OperationRun;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Collection;
class RecentOperationsSummary extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'tenant' => null,
'runs' => collect(),
'operationsIndexUrl' => route('admin.operations.index'),
];
}
/** @var Collection<int, OperationRun> $runs */
$runs = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at')
->orderByDesc('id')
->limit(5)
->get([
'id',
'type',
'status',
'outcome',
'created_at',
'started_at',
'completed_at',
]);
return [
'tenant' => $tenant,
'runs' => $runs,
'operationsIndexUrl' => route('admin.operations.index'),
];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class ClearTenantContextController
{
public function __invoke(Request $request): RedirectResponse
{
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId($request);
return redirect()->to('/admin/operations');
}
}

View File

@ -49,7 +49,9 @@ public function __invoke(Request $request): RedirectResponse
$this->persistLastTenant($user, $tenant);
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void

View File

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -44,6 +45,12 @@ public function __invoke(Request $request): RedirectResponse
$context->setCurrentWorkspace($workspace, $user, $request);
$intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) {
return redirect()->to($intendedUrl);
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
@ -58,7 +65,7 @@ public function __invoke(Request $request): RedirectResponse
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
}

View File

@ -3,11 +3,14 @@
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
@ -28,27 +31,14 @@ public function handle(Request $request, Closure $next): Response
$path = '/'.ltrim($request->path(), '/');
if ($this->isWorkspaceOptionalPath($request, $path)) {
return $next($request);
}
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return $next($request);
}
}
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
@ -73,8 +63,42 @@ public function handle(Request $request, Closure $next): Response
->exists()
: $membershipQuery->exists();
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
abort(404);
}
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
? '/admin/choose-workspace'
: '/admin/no-access';
if ($target === '/admin/choose-workspace') {
WorkspaceIntendedUrl::storeFromRequest($request);
}
return new HttpResponse('', 302, ['Location' => $target]);
}
private function isWorkspaceOptionalPath(Request $request, string $path): bool
{
if (str_starts_with($path, '/admin/workspaces')) {
return true;
}
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) {
return true;
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return true;
}
}
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
}
}

View File

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

View File

@ -1,28 +0,0 @@
<?php
namespace App\Livewire\Monitoring;
use App\Models\OperationRun;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class OperationsDetail extends Component implements HasForms
{
use InteractsWithForms;
public OperationRun $run;
public function mount(OperationRun $run): void
{
// Ensure tenant scope
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
$this->run = $run;
}
public function render(): View
{
return view('livewire.monitoring.operations-detail');
}
}

View File

@ -177,6 +177,11 @@ public static function currentOrFail(): self
return $tenant;
}
public function getRouteKeyName(): string
{
return 'external_id';
}
public function resolveRouteBinding($value, $field = null): ?Model
{
$field ??= $this->getRouteKeyName();

View File

@ -30,6 +30,7 @@ class TenantOnboardingSession extends Model
'bootstrap_operation_types',
'bootstrap_operation_runs',
'bootstrap_run_ids',
'connection_recently_updated',
];
protected $guarded = [];

View File

@ -22,7 +22,7 @@ public function viewAny(User $user): bool
return false;
}
$tenant = Tenant::current();
$tenant = $this->currentTenant();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -64,7 +64,7 @@ public function create(User $user): bool
return false;
}
$tenant = Tenant::current();
$tenant = $this->currentTenant();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -135,4 +135,21 @@ private function currentWorkspace(): ?Workspace
? Workspace::query()->whereKey($workspaceId)->first()
: null;
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -7,45 +7,54 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\Response;
class WorkspacePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
public function viewAny(User $user): bool|Response
{
return true;
return Response::allow();
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Workspace $workspace): bool
public function view(User $user, Workspace $workspace): bool|Response
{
return WorkspaceMembership::query()
$isMember = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
return $isMember ? Response::allow() : Response::denyAsNotFound();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
public function create(User $user): bool|Response
{
return true;
return Response::allow();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Workspace $workspace): bool
public function update(User $user, Workspace $workspace): bool|Response
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
if (! $resolver->isMember($user, $workspace)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)
? Response::allow()
: Response::deny();
}
/**

View File

@ -6,11 +6,14 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -34,7 +37,6 @@ class AdminPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('admin')
->path('admin')
->login(Login::class)
@ -45,29 +47,54 @@ public function panel(Panel $panel): Panel
WorkspaceResource::registerRoutes($panel);
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Workspaces')
NavigationItem::make('Manage workspaces')
->url(function (): string {
return route('filament.admin.resources.workspaces.index');
})
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10)
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
return WorkspaceMembership::query()
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}),
NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
NavigationItem::make('Alerts')
->url(fn (): string => route('admin.monitoring.alerts'))
->icon('heroicon-o-bell-alert')
->group('Monitoring')
->sort(20),
NavigationItem::make('Audit Log')
->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list')
->group('Monitoring')
->sort(30),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
fn () => view('filament.partials.workspace-switcher')->render()
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
@ -75,13 +102,13 @@ public function panel(Panel $panel): Panel
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
TenantDashboard::class,
->resources([
TenantResource::class,
ProviderConnectionResource::class,
])
->pages([
TenantRequiredPermissions::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
@ -97,8 +124,6 @@ public function panel(Panel $panel): Panel
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])

View File

@ -0,0 +1,94 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class TenantPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('tenant')
->path('admin/t')
->login(Login::class)
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->colors([
'primary' => Color::Amber,
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
TenantDashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
])
->databaseNotifications()
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
if (! app()->runningUnitTests()) {
$panel->viteTheme('resources/css/filament/admin/theme.css');
}
return $panel;
}
}

View File

@ -587,7 +587,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
action: 'get_service_principal_permissions',
response: $assignmentsResponse,
transform: function (array $json) use ($context) {
$assignments = $json['value'] ?? [];
$assignments = is_array($json['value'] ?? null) ? $json['value'] : [];
$assignmentsTotal = count($assignments);
$permissions = [];
// Get Microsoft Graph service principal to map role IDs to permission names
@ -605,22 +606,49 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$graphSps = $graphSpResponse instanceof Response
? $graphSpResponse->json('value', [])
: [];
$appRoles = $graphSps[0]['appRoles'] ?? [];
$appRoles = is_array($graphSps[0]['appRoles'] ?? null) ? $graphSps[0]['appRoles'] : [];
// Map role IDs to permission names
$roleMap = [];
foreach ($appRoles as $role) {
$roleMap[$role['id']] = $role['value'];
$roleId = $role['id'] ?? null;
$value = $role['value'] ?? null;
if (! is_string($roleId) || $roleId === '') {
continue;
}
if (! is_string($value) || $value === '') {
continue;
}
$roleMap[strtolower($roleId)] = $value;
}
foreach ($assignments as $assignment) {
$roleId = $assignment['appRoleId'] ?? null;
if ($roleId && isset($roleMap[$roleId])) {
$permissions[] = $roleMap[$roleId];
if (! is_string($roleId) || $roleId === '') {
continue;
}
$normalizedRoleId = strtolower($roleId);
if (isset($roleMap[$normalizedRoleId])) {
$permissions[] = $roleMap[$normalizedRoleId];
}
}
return ['permissions' => $permissions];
$permissions = array_values(array_unique($permissions));
return [
'permissions' => $permissions,
'diagnostics' => [
'assignments_total' => $assignmentsTotal,
'mapped_total' => count($permissions),
'graph_roles_total' => count($roleMap),
],
];
},
meta: [
'tenant' => $context['tenant'] ?? null,

View File

@ -40,27 +40,79 @@ public function getGrantedPermissions(Tenant $tenant): array
* @param bool $persist Persist comparison results to tenant_permissions
* @param bool $liveCheck If true, fetch actual permissions from Graph API
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
* @return array{
* overall_status:string,
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
* }
*/
public function compare(
Tenant $tenant,
?array $grantedStatuses = null,
bool $persist = true,
bool $liveCheck = false,
bool $useConfiguredStub = true
bool $useConfiguredStub = true,
?array $graphOptions = null,
): array {
$required = $this->getRequiredPermissions();
$liveCheckMeta = [
'attempted' => false,
'succeeded' => false,
'http_status' => null,
'reason_code' => null,
];
$liveCheckFailed = false;
$liveCheckDetails = null;
// If liveCheck is requested, fetch actual permissions from Graph
if ($liveCheck && $grantedStatuses === null) {
$grantedStatuses = $this->fetchLivePermissions($tenant);
$liveCheckMeta['attempted'] = true;
$appId = null;
if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
$appId = (string) $graphOptions['client_id'];
} elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') {
$appId = (string) $tenant->graphOptions()['client_id'];
}
if ($appId !== null) {
$liveCheckMeta['app_id'] = $appId;
}
$grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions);
if (isset($grantedStatuses['__error'])) {
$liveCheckFailed = true;
$liveCheckDetails = $grantedStatuses['__error']['details'] ?? null;
$liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null;
$liveCheckDetails = is_array($liveCheckError['details'] ?? null)
? $liveCheckError['details']
: (is_array($liveCheckError) ? $liveCheckError : null);
$httpStatus = $liveCheckDetails['status'] ?? null;
$liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null;
$liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode(
$liveCheckMeta['http_status'],
is_array($liveCheckDetails) ? $liveCheckDetails : null,
);
unset($grantedStatuses['__error']);
$grantedStatuses = null;
} else {
$observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0;
$liveCheckMeta['observed_permissions_count'] = $observedCount;
if ($observedCount === 0) {
// Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh.
// This prevents false "missing" findings due to partial/misconfigured verification context.
$liveCheckMeta['succeeded'] = false;
$liveCheckMeta['reason_code'] = 'permissions_inventory_empty';
$grantedStatuses = null;
} else {
$liveCheckMeta['succeeded'] = true;
$liveCheckMeta['reason_code'] = 'ok';
}
}
}
@ -81,16 +133,48 @@ public function compare(
$hasErrors = false;
$checkedAt = now();
$canPersist = $persist;
if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
// When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot
// only if we have no stored inventory yet, so the UI can explain the failure.
$reasonCode = is_string($liveCheckMeta['reason_code'] ?? null)
? (string) $liveCheckMeta['reason_code']
: null;
$shouldPersistErrorSnapshot = in_array($reasonCode, [
'authentication_failed',
'permission_denied',
], true);
if (! $shouldPersistErrorSnapshot) {
$canPersist = false;
} else {
$hasStoredStatuses = TenantPermission::query()
->where('tenant_id', $tenant->id)
->exists();
$canPersist = ! $hasStoredStatuses;
}
}
foreach ($required as $permission) {
$key = $permission['key'];
$status = $liveCheckFailed
? 'error'
: ($granted[$key]['status'] ?? 'missing');
$details = $liveCheckFailed
? ($liveCheckDetails ?? ['source' => 'graph_api'])
? array_filter([
'source' => 'graph_api',
'status' => $liveCheckMeta['http_status'],
'reason_code' => $liveCheckMeta['reason_code'],
'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null,
], fn (mixed $value): bool => $value !== null)
: ($granted[$key]['details'] ?? null);
if ($persist) {
if ($canPersist) {
TenantPermission::updateOrCreate(
[
'tenant_id' => $tenant->id,
@ -123,10 +207,36 @@ public function compare(
default => 'granted',
};
return [
$payload = [
'overall_status' => $overall,
'permissions' => $results,
];
if ($liveCheckMeta['attempted'] === true) {
$payload['live_check'] = $liveCheckMeta;
}
return $payload;
}
/**
* @param array<string, mixed>|null $details
*/
private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string
{
if (is_array($details) && is_string($details['reason_code'] ?? null)) {
return (string) $details['reason_code'];
}
return match (true) {
$httpStatus === 401 => 'authentication_failed',
$httpStatus === 403 => 'permission_denied',
$httpStatus === 408 => 'dependency_unreachable',
$httpStatus === 429 => 'throttled',
is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable',
is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error',
default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error',
};
}
/**
@ -211,11 +321,11 @@ private function configuredGrantedKeys(): array
*
* @return array<string, array{status:string,details:array<string,mixed>|null}>
*/
private function fetchLivePermissions(Tenant $tenant): array
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
{
try {
$response = $this->graphClient->getServicePrincipalPermissions(
$tenant->graphOptions()
$graphOptions ?? $tenant->graphOptions()
);
if (! $response->success) {
@ -232,6 +342,25 @@ private function fetchLivePermissions(Tenant $tenant): array
}
$grantedPermissions = $response->data['permissions'] ?? [];
$diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null;
$assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0;
$mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null;
if ($assignmentsTotal > 0 && $mappedTotal === 0) {
return [
'__error' => [
'status' => 'error',
'details' => [
'source' => 'graph_api',
'status' => $response->status,
'reason_code' => 'permission_mapping_failed',
'message' => 'Graph returned app role assignments, but the system could not map them to permission values.',
'diagnostics' => $diagnostics,
],
],
];
}
$normalized = [];
foreach ($grantedPermissions as $permission) {

View File

@ -0,0 +1,389 @@
<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Support\Verification\VerificationReportOverall;
class TenantRequiredPermissionsViewModelBuilder
{
/**
* @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
* @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
* @phpstan-type ViewModel array{
* tenant: array{id:int,external_id:string,name:string},
* overview: array{
* overall: string,
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
* feature_impacts: array<int, FeatureImpact>
* },
* permissions: array<int, TenantPermissionRow>,
* filters: FilterState,
* copy: array{application:string,delegated:string}
* }
*/
public function __construct(private readonly TenantPermissionService $permissionService) {}
/**
* @param array<string, mixed> $filters
* @return ViewModel
*/
public function build(Tenant $tenant, array $filters = []): array
{
$comparison = $this->permissionService->compare(
$tenant,
persist: false,
liveCheck: false,
useConfiguredStub: false,
);
/** @var array<int, TenantPermissionRow> $allPermissions */
$allPermissions = collect($comparison['permissions'] ?? [])
->filter(fn (mixed $row): bool => is_array($row))
->map(fn (array $row): array => self::normalizePermissionRow($row))
->values()
->all();
$state = self::normalizeFilterState($filters);
$filteredPermissions = self::applyFilterState($allPermissions, $state);
return [
'tenant' => [
'id' => (int) $tenant->getKey(),
'external_id' => (string) $tenant->external_id,
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => self::deriveOverallStatus($allPermissions),
'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
],
'permissions' => $filteredPermissions,
'filters' => $state,
'copy' => [
'application' => self::deriveCopyPayload($allPermissions, 'application', $state['features']),
'delegated' => self::deriveCopyPayload($allPermissions, 'delegated', $state['features']),
],
];
}
/**
* @param array<int, TenantPermissionRow> $permissions
*/
public static function deriveOverallStatus(array $permissions): string
{
$hasMissingApplication = collect($permissions)->contains(
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
);
if ($hasMissingApplication) {
return VerificationReportOverall::Blocked->value;
}
$hasErrors = collect($permissions)->contains(
fn (array $row): bool => $row['status'] === 'error',
);
$hasMissingDelegated = collect($permissions)->contains(
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
);
if ($hasErrors || $hasMissingDelegated) {
return VerificationReportOverall::NeedsAttention->value;
}
return VerificationReportOverall::Ready->value;
}
/**
* @param array<int, TenantPermissionRow> $permissions
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
*/
public static function deriveCounts(array $permissions): array
{
$counts = [
'missing_application' => 0,
'missing_delegated' => 0,
'present' => 0,
'error' => 0,
];
foreach ($permissions as $row) {
if (($row['status'] ?? null) === 'missing') {
if (($row['type'] ?? null) === 'delegated') {
$counts['missing_delegated'] += 1;
} else {
$counts['missing_application'] += 1;
}
continue;
}
if (($row['status'] ?? null) === 'granted') {
$counts['present'] += 1;
continue;
}
if (($row['status'] ?? null) === 'error') {
$counts['error'] += 1;
}
}
return $counts;
}
/**
* @param array<int, TenantPermissionRow> $permissions
* @return array<int, FeatureImpact>
*/
public static function deriveFeatureImpacts(array $permissions): array
{
/** @var array<string, FeatureImpact> $impacts */
$impacts = [];
foreach ($permissions as $row) {
$features = array_values(array_unique($row['features'] ?? []));
foreach ($features as $feature) {
if (! isset($impacts[$feature])) {
$impacts[$feature] = [
'feature' => $feature,
'missing' => 0,
'required_application' => 0,
'required_delegated' => 0,
'blocked' => false,
];
}
if (($row['type'] ?? null) === 'delegated') {
$impacts[$feature]['required_delegated'] += 1;
} else {
$impacts[$feature]['required_application'] += 1;
}
if (($row['status'] ?? null) === 'missing') {
$impacts[$feature]['missing'] += 1;
if (($row['type'] ?? null) === 'application') {
$impacts[$feature]['blocked'] = true;
}
}
}
}
$values = array_values($impacts);
usort($values, static function (array $a, array $b): int {
$blocked = (int) ($b['blocked'] <=> $a['blocked']);
if ($blocked !== 0) {
return $blocked;
}
$missing = (int) (($b['missing'] ?? 0) <=> ($a['missing'] ?? 0));
if ($missing !== 0) {
return $missing;
}
return strcmp((string) ($a['feature'] ?? ''), (string) ($b['feature'] ?? ''));
});
return $values;
}
/**
* Copy payload semantics:
* - Always Missing-only
* - Always Type fixed by button (application vs delegated)
* - Respects Feature filter only
* - Ignores Search
*
* @param array<int, TenantPermissionRow> $permissions
* @param 'application'|'delegated' $type
* @param array<int, string> $featureFilter
*/
public static function deriveCopyPayload(array $permissions, string $type, array $featureFilter = []): string
{
$featureFilter = array_values(array_unique(array_filter(array_map('strval', $featureFilter))));
$payload = collect($permissions)
->filter(function (array $row) use ($type, $featureFilter): bool {
if (($row['status'] ?? null) !== 'missing') {
return false;
}
if (($row['type'] ?? null) !== $type) {
return false;
}
if ($featureFilter === []) {
return true;
}
$rowFeatures = $row['features'] ?? [];
return count(array_intersect($featureFilter, $rowFeatures)) > 0;
})
->pluck('key')
->map(fn (mixed $key): string => (string) $key)
->filter()
->unique()
->sort()
->values()
->all();
return implode("\n", $payload);
}
/**
* @param array<int, TenantPermissionRow> $permissions
* @return array<int, TenantPermissionRow>
*/
public static function applyFilterState(array $permissions, array $state): array
{
$status = $state['status'] ?? 'missing';
$type = $state['type'] ?? 'all';
$features = $state['features'] ?? [];
$search = $state['search'] ?? '';
$search = is_string($search) ? trim($search) : '';
$searchLower = strtolower($search);
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
$filtered = collect($permissions)
->filter(function (array $row) use ($status, $type, $features): bool {
$rowStatus = $row['status'] ?? null;
$rowType = $row['type'] ?? null;
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
return false;
}
if ($status === 'present' && $rowStatus !== 'granted') {
return false;
}
if ($type !== 'all' && $rowType !== $type) {
return false;
}
if ($features === []) {
return true;
}
$rowFeatures = $row['features'] ?? [];
return count(array_intersect($features, $rowFeatures)) > 0;
})
->when($searchLower !== '', function ($collection) use ($searchLower) {
return $collection->filter(function (array $row) use ($searchLower): bool {
$key = strtolower((string) ($row['key'] ?? ''));
$description = strtolower((string) ($row['description'] ?? ''));
return str_contains($key, $searchLower) || ($description !== '' && str_contains($description, $searchLower));
});
})
->values()
->all();
usort($filtered, static function (array $a, array $b): int {
$weight = static function (array $row): int {
return match ($row['status'] ?? null) {
'missing' => 0,
'error' => 1,
default => 2,
};
};
$cmp = $weight($a) <=> $weight($b);
if ($cmp !== 0) {
return $cmp;
}
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
});
return $filtered;
}
/**
* @param array<string, mixed> $filters
* @return FilterState
*/
public static function normalizeFilterState(array $filters): array
{
$status = (string) ($filters['status'] ?? 'missing');
$type = (string) ($filters['type'] ?? 'all');
$features = $filters['features'] ?? [];
$search = (string) ($filters['search'] ?? '');
if (! in_array($status, ['missing', 'present', 'all'], true)) {
$status = 'missing';
}
if (! in_array($type, ['application', 'delegated', 'all'], true)) {
$type = 'all';
}
if (! is_array($features)) {
$features = [];
}
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
return [
'status' => $status,
'type' => $type,
'features' => $features,
'search' => $search,
];
}
/**
* @param array<string, mixed> $row
* @return TenantPermissionRow
*/
private static function normalizePermissionRow(array $row): array
{
$key = (string) ($row['key'] ?? '');
$type = (string) ($row['type'] ?? 'application');
$description = $row['description'] ?? null;
$features = $row['features'] ?? [];
$status = (string) ($row['status'] ?? 'missing');
$details = $row['details'] ?? null;
if (! in_array($type, ['application', 'delegated'], true)) {
$type = 'application';
}
if (! is_string($description) || $description === '') {
$description = null;
}
if (! is_array($features)) {
$features = [];
}
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
$status = 'missing';
}
if (! is_array($details)) {
$details = null;
}
return [
'key' => $key,
'type' => $type,
'description' => $description,
'features' => $features,
'status' => $status,
'details' => $details,
];
}
}

View File

@ -74,4 +74,21 @@ public function upsertClientSecretCredential(
],
);
}
public function updateClientIdPreservingSecret(ProviderConnection $connection, string $clientId): ProviderCredential
{
$clientId = trim($clientId);
if ($clientId === '') {
throw new InvalidArgumentException('client_id is required.');
}
$existing = $this->getClientCredentials($connection);
return $this->upsertClientSecretCredential(
connection: $connection,
clientId: $clientId,
clientSecret: (string) $existing['client_secret'],
);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Support\Links;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
final class RequiredPermissionsLinks
{
private const ADMIN_CONSENT_GUIDE_URL = 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent';
/**
* @param array<string, mixed> $filters
*/
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
{
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
if ($filters === []) {
return $base;
}
$query = http_build_query($filters);
return $query !== '' ? "{$base}?{$query}" : $base;
}
public static function adminConsentUrl(Tenant $tenant): ?string
{
return TenantResource::adminConsentUrl($tenant);
}
public static function adminConsentGuideUrl(): string
{
return self::ADMIN_CONSENT_GUIDE_URL;
}
public static function adminConsentPrimaryUrl(Tenant $tenant): string
{
return self::adminConsentUrl($tenant) ?? self::adminConsentGuideUrl();
}
}

View File

@ -2,11 +2,12 @@
namespace App\Support\Middleware;
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
@ -27,6 +28,14 @@ public function handle(Request $request, Closure $next): Response
$path = '/'.ltrim($request->path(), '/');
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId($request);
$existingTenant = Filament::getTenant();
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
Filament::setTenant(null, true);
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
@ -44,6 +53,12 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/operations') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($request->route()?->hasParameter('tenant')) {
$user = $request->user();
@ -66,9 +81,6 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId($request);
if ($workspaceId === null) {
abort(404);
}
@ -92,6 +104,9 @@ public function handle(Request $request, Closure $next): Response
}
Filament::setTenant($tenant, true);
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
$this->configureNavigationForRequest($panel);
return $next($request);
@ -100,7 +115,8 @@ public function handle(Request $request, Closure $next): Response
if (
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|| str_starts_with($path, '/admin/operations')
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding'], true)
) {
$this->configureNavigationForRequest($panel);
@ -121,60 +137,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenant = null;
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId !== null) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->first();
if (! $tenant) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->first();
}
}
if (! $tenant) {
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
}
}
if (! $tenant) {
$tenant = $user->tenants()
->where('status', 'active')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()->first();
}
if (! $tenant) {
$tenant = $user->tenants()->withTrashed()->first();
}
if ($tenant) {
Filament::setTenant($tenant, true);
}
$this->configureNavigationForRequest($panel);
return $next($request);
@ -195,11 +157,46 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
$panel->navigation(function (): NavigationBuilder {
return app(NavigationBuilder::class)
->item(
NavigationItem::make('Workspaces')
->url(fn (): string => ChooseWorkspace::getUrl())
NavigationItem::make('Manage workspaces')
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10)
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
return WorkspaceMembership::query()
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}),
)
->item(
NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
)
->item(
NavigationItem::make('Alerts')
->url(fn (): string => '/admin/alerts')
->icon('heroicon-o-bell-alert')
->group('Monitoring')
->sort(20),
)
->item(
NavigationItem::make('Audit Log')
->url(fn (): string => '/admin/audit-log')
->icon('heroicon-o-clipboard-document-list')
->group('Monitoring')
->sort(30),
);
});
}

View File

@ -7,7 +7,6 @@
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\RestoreRunResource;
@ -16,9 +15,9 @@
final class OperationRunLinks
{
public static function index(Tenant $tenant): string
public static function index(?Tenant $tenant = null): string
{
return OperationRunResource::getUrl('index', tenant: $tenant);
return route('admin.operations.index');
}
public static function tenantlessView(OperationRun|int $run): string
@ -30,13 +29,13 @@ public static function tenantlessView(OperationRun|int $run): string
public static function view(OperationRun|int $run, Tenant $tenant): string
{
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
return self::tenantlessView($run);
}
/**
* @return array<string, string>
*/
public static function related(OperationRun $run, Tenant $tenant): array
public static function related(OperationRun $run, ?Tenant $tenant): array
{
$context = is_array($run->context) ? $run->context : [];
@ -44,53 +43,57 @@ public static function related(OperationRun $run, Tenant $tenant): array
$links['Operations'] = self::index($tenant);
if (! $tenant instanceof Tenant) {
return $links;
}
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant);
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant);
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
}
if ($run->type === 'inventory.sync') {
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
}
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$policyId = $context['policy_id'] ?? null;
if (is_numeric($policyId)) {
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
}
}
if ($run->type === 'directory_groups.sync') {
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
}
if ($run->type === 'drift.generate') {
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
}
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null;
if (is_numeric($backupSetId)) {
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
}
}
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
}
if ($run->type === 'restore.execute') {
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null;
if (is_numeric($restoreRunId)) {
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
}
}

View File

@ -0,0 +1,415 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
final class TenantPermissionCheckClusters
{
/**
* @phpstan-type TenantPermissionRow array{
* key:string,
* type:'application'|'delegated',
* description:?string,
* features:array<int,string>,
* status:'granted'|'missing'|'error',
* details:array<string,mixed>|null
* }
*
* @param array<int, array<string, mixed>> $permissions
* @param array{fresh?:bool,reason_code?:string,message?:string}|null $inventory
* @return array<int, array<string, mixed>>
*/
public static function buildChecks(Tenant $tenant, array $permissions, ?array $inventory = null): array
{
$inventory = is_array($inventory) ? $inventory : [];
$inventoryFresh = $inventory['fresh'] ?? true;
$inventoryFresh = is_bool($inventoryFresh) ? $inventoryFresh : true;
$inventoryReasonCode = $inventory['reason_code'] ?? null;
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
? $inventoryReasonCode
: 'dependency_unreachable';
$inventoryMessage = $inventory['message'] ?? null;
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
? trim($inventoryMessage)
: 'Unable to refresh observed permissions inventory during this run. Retry verification.';
$inventoryEvidence = self::inventoryEvidence($inventory);
/** @var array<int, TenantPermissionRow> $rows */
$rows = collect($permissions)
->filter(fn (mixed $row): bool => is_array($row))
->map(fn (array $row): array => self::normalizePermissionRow($row))
->values()
->all();
$checks = [];
foreach (self::definitions() as $definition) {
$key = (string) ($definition['key'] ?? 'unknown');
$title = (string) ($definition['title'] ?? 'Check');
$clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matches($definition, $row)));
$checks[] = self::buildCheck(
tenant: $tenant,
key: $key,
title: $title,
clusterRows: $clusterRows,
inventoryFresh: $inventoryFresh,
inventoryReasonCode: $inventoryReasonCode,
inventoryMessage: $inventoryMessage,
inventoryEvidence: $inventoryEvidence,
);
}
return $checks;
}
/**
* @return array<int, array{key:string,title:string,mode:string,prefixes?:array<int,string>,keys?:array<int,string>}>
*/
private static function definitions(): array
{
return [
[
'key' => 'permissions.admin_consent',
'title' => 'Admin consent granted',
'mode' => 'type',
'type' => 'application',
],
[
'key' => 'permissions.directory_groups',
'title' => 'Directory & group read access',
'mode' => 'keys',
'keys' => [
'Directory.Read.All',
'Group.Read.All',
],
],
[
'key' => 'permissions.intune_configuration',
'title' => 'Intune configuration access',
'mode' => 'prefixes',
'prefixes' => [
'DeviceManagementConfiguration.',
'DeviceManagementServiceConfig.',
],
],
[
'key' => 'permissions.intune_apps',
'title' => 'Intune apps access',
'mode' => 'prefixes',
'prefixes' => [
'DeviceManagementApps.',
],
],
[
'key' => 'permissions.intune_rbac_assignments',
'title' => 'Intune RBAC & assignments prerequisites',
'mode' => 'prefixes',
'prefixes' => [
'DeviceManagementRBAC.',
],
],
[
'key' => 'permissions.scripts_remediations',
'title' => 'Scripts/remediations access',
'mode' => 'prefixes',
'prefixes' => [
'DeviceManagementScripts.',
],
],
];
}
/**
* @param array{mode:string,prefixes?:array<int,string>,keys?:array<int,string>,type?:string} $definition
* @param TenantPermissionRow $row
*/
private static function matches(array $definition, array $row): bool
{
$mode = (string) ($definition['mode'] ?? '');
$key = (string) ($row['key'] ?? '');
if ($mode === 'type') {
return ($row['type'] ?? null) === ($definition['type'] ?? null);
}
if ($mode === 'keys') {
$keys = $definition['keys'] ?? [];
return is_array($keys) && in_array($key, $keys, true);
}
if ($mode === 'prefixes') {
$prefixes = $definition['prefixes'] ?? [];
if (! is_array($prefixes)) {
return false;
}
foreach ($prefixes as $prefix) {
if (is_string($prefix) && $prefix !== '' && str_starts_with($key, $prefix)) {
return true;
}
}
return false;
}
return false;
}
/**
* @param array<int, TenantPermissionRow> $clusterRows
* @return array<string, mixed>
*/
private static function buildCheck(
Tenant $tenant,
string $key,
string $title,
array $clusterRows,
bool $inventoryFresh,
string $inventoryReasonCode,
string $inventoryMessage,
array $inventoryEvidence,
): array
{
if (! $inventoryFresh) {
return [
'key' => $key,
'title' => $title,
'status' => VerificationCheckStatus::Warn->value,
'severity' => VerificationCheckSeverity::Medium->value,
'blocking' => false,
'reason_code' => $inventoryReasonCode,
'message' => $inventoryMessage,
'evidence' => $inventoryEvidence,
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
if ($clusterRows === []) {
return [
'key' => $key,
'title' => $title,
'status' => VerificationCheckStatus::Skip->value,
'severity' => VerificationCheckSeverity::Info->value,
'blocking' => false,
'reason_code' => 'not_applicable',
'message' => 'Not applicable for this tenant.',
'evidence' => [],
'next_steps' => [],
];
}
$missingApplication = array_values(array_filter(
$clusterRows,
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
));
$missingDelegated = array_values(array_filter(
$clusterRows,
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
));
$errored = array_values(array_filter(
$clusterRows,
static fn (array $row): bool => $row['status'] === 'error',
));
$evidence = array_values(array_unique(array_merge(
self::evidence($missingApplication, $missingDelegated, $errored),
$inventoryEvidence,
), SORT_REGULAR));
if ($missingApplication !== [] || $errored !== []) {
$missingKeys = array_values(array_unique(array_merge(
array_map(static fn (array $row): string => $row['key'], $missingApplication),
array_map(static fn (array $row): string => $row['key'], $errored),
)));
$message = $missingKeys !== []
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
: 'Missing required permissions.';
return [
'key' => $key,
'title' => $title,
'status' => VerificationCheckStatus::Fail->value,
'severity' => VerificationCheckSeverity::Critical->value,
'blocking' => true,
'reason_code' => 'ext.missing_permission',
'message' => $message,
'evidence' => $evidence,
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
if ($missingDelegated !== []) {
$missingKeys = array_values(array_unique(array_map(static fn (array $row): string => $row['key'], $missingDelegated)));
$message = sprintf('Missing delegated permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)));
return [
'key' => $key,
'title' => $title,
'status' => VerificationCheckStatus::Warn->value,
'severity' => VerificationCheckSeverity::Medium->value,
'blocking' => false,
'reason_code' => 'ext.missing_delegated_permission',
'message' => $message,
'evidence' => $evidence,
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
return [
'key' => $key,
'title' => $title,
'status' => VerificationCheckStatus::Pass->value,
'severity' => VerificationCheckSeverity::Info->value,
'blocking' => false,
'reason_code' => 'ok',
'message' => 'All required permissions are granted.',
'evidence' => [],
'next_steps' => [],
];
}
/**
* @param array<int, TenantPermissionRow> $missingApplication
* @param array<int, TenantPermissionRow> $missingDelegated
* @param array<int, TenantPermissionRow> $errored
* @return array<int, array{kind:string,value:int|string}>
*/
private static function evidence(array $missingApplication, array $missingDelegated, array $errored): array
{
$pointers = [];
foreach (array_merge($missingApplication, $missingDelegated, $errored) as $row) {
$pointers[] = [
'kind' => 'missing_permission',
'value' => (string) ($row['key'] ?? ''),
];
$pointers[] = [
'kind' => 'permission_type',
'value' => (string) ($row['type'] ?? 'application'),
];
foreach (($row['features'] ?? []) as $feature) {
if (! is_string($feature) || $feature === '') {
continue;
}
$pointers[] = [
'kind' => 'feature',
'value' => $feature,
];
}
}
$unique = [];
foreach ($pointers as $pointer) {
$key = $pointer['kind'].':'.(string) $pointer['value'];
$unique[$key] = $pointer;
}
return array_values($unique);
}
/**
* @param array<string, mixed> $inventory
* @return array<int, array{kind:string,value:int|string}>
*/
private static function inventoryEvidence(array $inventory): array
{
$pointers = [];
$appId = $inventory['app_id'] ?? null;
if (is_string($appId) && $appId !== '') {
$pointers[] = [
'kind' => 'app_id',
'value' => $appId,
];
}
$observedCount = $inventory['observed_permissions_count'] ?? null;
if (is_int($observedCount) || (is_numeric($observedCount) && (string) (int) $observedCount === (string) $observedCount)) {
$pointers[] = [
'kind' => 'observed_permissions_count',
'value' => (int) $observedCount,
];
}
return $pointers;
}
/**
* @param array<string, mixed> $row
* @return TenantPermissionRow
*/
private static function normalizePermissionRow(array $row): array
{
$key = (string) ($row['key'] ?? '');
$type = (string) ($row['type'] ?? 'application');
$status = (string) ($row['status'] ?? 'missing');
$description = $row['description'] ?? null;
$features = $row['features'] ?? [];
$details = $row['details'] ?? null;
if (! in_array($type, ['application', 'delegated'], true)) {
$type = 'application';
}
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
$status = 'missing';
}
if (! is_string($description) || $description === '') {
$description = null;
}
if (! is_array($features)) {
$features = [];
}
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
if (! is_array($details)) {
$details = null;
}
return [
'key' => $key,
'type' => $type,
'description' => $description,
'features' => $features,
'status' => $status,
'details' => $details,
];
}
}

View File

@ -17,6 +17,23 @@ final class VerificationReportSanitizer
'set-cookie',
];
/**
* Evidence pointers must remain pointer-only. This allowlist is intentionally strict.
*
* @var array<int, string>
*/
private const ALLOWED_EVIDENCE_KINDS = [
'provider_connection_id',
'entra_tenant_id',
'organization_id',
'http_status',
'app_id',
'observed_permissions_count',
'missing_permission',
'permission_type',
'feature',
];
/**
* @return array<string, mixed>
*/
@ -250,6 +267,12 @@ private static function sanitizeEvidence(array $evidence): array
continue;
}
$kind = trim($kind);
if (! in_array($kind, self::ALLOWED_EVIDENCE_KINDS, true)) {
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
@ -257,7 +280,7 @@ private static function sanitizeEvidence(array $evidence): array
$value = $pointer['value'] ?? null;
if (is_int($value)) {
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
$sanitized[] = ['kind' => $kind, 'value' => $value];
continue;
}
@ -272,7 +295,7 @@ private static function sanitizeEvidence(array $evidence): array
continue;
}
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
}
return $sanitized;

View File

@ -11,6 +11,10 @@ final class WorkspaceContext
{
public const SESSION_KEY = 'current_workspace_id';
public const INTENDED_URL_SESSION_KEY = 'workspace_intended_url';
public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids';
public function __construct(private WorkspaceResolver $resolver) {}
public function currentWorkspaceId(?Request $request = null): ?int
@ -53,6 +57,54 @@ public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?R
}
}
public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
$map[(string) $workspaceId] = $tenantId;
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
}
public function lastTenantId(?Request $request = null): ?int
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null) {
return null;
}
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
$id = $map[(string) $workspaceId] ?? null;
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function clearLastTenantId(?Request $request = null): void
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null) {
return;
}
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
unset($map[(string) $workspaceId]);
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Support\Workspaces;
use Illuminate\Http\Request;
use Illuminate\Session\Store;
final class WorkspaceIntendedUrl
{
/**
* Store a safe intended URL (path + query) for returning after workspace selection.
*/
public static function store(string $pathWithQuery, ?Request $request = null): void
{
$pathWithQuery = trim($pathWithQuery);
if ($pathWithQuery === '') {
return;
}
if (! self::isAllowed($pathWithQuery)) {
return;
}
$session = self::session($request);
if (! $session instanceof Store) {
return;
}
$session->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, $pathWithQuery);
}
/**
* Store the intended URL derived from the current request.
*/
public static function storeFromRequest(Request $request): void
{
if (! $request->isMethod('GET')) {
return;
}
$path = '/'.ltrim($request->path(), '/');
$queryString = $request->getQueryString();
$pathWithQuery = $queryString ? "{$path}?{$queryString}" : $path;
self::store($pathWithQuery, $request);
}
/**
* Consume (read + forget) the intended URL. Returns null if missing or unsafe.
*/
public static function consume(?Request $request = null): ?string
{
$session = self::session($request);
if (! $session instanceof Store) {
return null;
}
$value = $session->pull(WorkspaceContext::INTENDED_URL_SESSION_KEY);
if (! is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || ! self::isAllowed($value)) {
return null;
}
return $value;
}
public static function clear(?Request $request = null): void
{
$session = self::session($request);
if (! $session instanceof Store) {
return;
}
$session->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
}
private static function session(?Request $request = null): ?Store
{
$session = ($request && $request->hasSession())
? $request->session()
: session()->driver();
return $session instanceof Store ? $session : null;
}
private static function isAllowed(string $pathWithQuery): bool
{
if (str_contains($pathWithQuery, "\n") || str_contains($pathWithQuery, "\r")) {
return false;
}
if (preg_match('#^https?://#i', $pathWithQuery) === 1) {
return false;
}
if (str_starts_with($pathWithQuery, '//')) {
return false;
}
if (! str_starts_with($pathWithQuery, '/admin')) {
return false;
}
$path = parse_url($pathWithQuery, PHP_URL_PATH);
$path = '/'.ltrim((string) ($path ?? ''), '/');
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access'], true)) {
return false;
}
return true;
}
}

View File

@ -4,5 +4,6 @@
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\TenantPanelProvider::class,
App\Providers\Filament\SystemPanelProvider::class,
];

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
if ($driver !== 'pgsql') {
// SQLite doesn't enforce UUID types; other drivers are not supported in this app.
return;
}
DB::statement('ALTER TABLE inventory_links ALTER COLUMN source_id TYPE text USING source_id::text');
DB::statement('ALTER TABLE inventory_links ALTER COLUMN target_id TYPE text USING target_id::text');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = DB::getDriverName();
if ($driver !== 'pgsql') {
return;
}
// Best-effort rollback: non-UUID identifiers are coerced.
$uuidRegex = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
$sentinel = '00000000-0000-0000-0000-000000000000';
DB::statement(
"ALTER TABLE inventory_links ALTER COLUMN source_id TYPE uuid USING (CASE WHEN source_id ~* '{$uuidRegex}' THEN source_id::uuid ELSE '{$sentinel}'::uuid END)"
);
DB::statement(
"ALTER TABLE inventory_links ALTER COLUMN target_id TYPE uuid USING (CASE WHEN target_id IS NULL THEN NULL WHEN target_id ~* '{$uuidRegex}' THEN target_id::uuid ELSE NULL END)"
);
}
};

View File

@ -0,0 +1,158 @@
@php
$run = $run ?? null;
$run = is_array($run) ? $run : null;
$runUrl = $runUrl ?? null;
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
$status = $run['status'] ?? null;
$status = is_string($status) ? $status : null;
$outcome = $run['outcome'] ?? null;
$outcome = is_string($outcome) ? $outcome : null;
$startedAt = $run['started_at'] ?? null;
$startedAt = is_string($startedAt) && $startedAt !== '' ? $startedAt : null;
$updatedAt = $run['updated_at'] ?? null;
$updatedAt = is_string($updatedAt) && $updatedAt !== '' ? $updatedAt : null;
$completedAt = $run['completed_at'] ?? null;
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
$hasReport = $hasReport ?? false;
$hasReport = is_bool($hasReport) ? $hasReport : false;
$formatTs = static function (?string $ts): ?string {
if ($ts === null) {
return null;
}
try {
return \Carbon\CarbonImmutable::parse($ts)->format('Y-m-d H:i');
} catch (\Throwable) {
return $ts;
}
};
$relativeTs = static function (?string $ts): ?string {
if ($ts === null) {
return null;
}
try {
return \Carbon\CarbonImmutable::parse($ts)->diffForHumans(null, true, true);
} catch (\Throwable) {
return null;
}
};
$targetScope = $run['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
$runStatusSpec = null;
if ($status !== null) {
$runStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, $status);
}
$runOutcomeSpec = null;
if ($outcome !== null && $status === 'completed') {
$runOutcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, $outcome);
}
$workerHint = match ($status) {
'queued' => 'Awaiting worker',
'running' => 'Worker running',
default => null,
};
@endphp
<div class="space-y-4">
@if ($run === null)
<div class="text-sm text-gray-600 dark:text-gray-300">
No verification run has been started yet.
</div>
@else
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium text-gray-900 dark:text-white">
Run #{{ (int) ($run['id'] ?? 0) }}
</div>
@if ($runStatusSpec)
<x-filament::badge :color="$runStatusSpec->color" :icon="$runStatusSpec->icon" size="sm">
{{ $runStatusSpec->label }}
</x-filament::badge>
@endif
@if ($runOutcomeSpec)
<x-filament::badge :color="$runOutcomeSpec->color" :icon="$runOutcomeSpec->icon" size="sm">
{{ $runOutcomeSpec->label }}
</x-filament::badge>
@endif
</div>
@if ($status !== 'completed')
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
@if ($workerHint)
<div>{{ $workerHint }}.</div>
@endif
@if (! $hasReport)
<div>No report yet. Refresh results in a moment.</div>
@else
<div>Partial results available. Use “Refresh results” to update the stored status in the wizard.</div>
@endif
</div>
@endif
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Operation</div>
<div class="mt-1 text-sm text-gray-900 dark:text-white">{{ (string) ($run['type'] ?? '—') }}</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope (Entra tenant)</div>
<div class="mt-1 text-sm text-gray-900 dark:text-white">{{ $entraTenantId ?? '—' }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Timestamps</div>
<dl class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
<div class="flex items-start justify-between gap-4">
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
<dd class="text-right">{{ $formatTs($startedAt) ?? '—' }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-gray-500 dark:text-gray-400">Last update</dt>
<dd class="text-right">
{{ $formatTs($updatedAt) ?? '—' }}
@if ($updatedAt !== null && ($relativeTs($updatedAt) !== null))
<span class="text-gray-500 dark:text-gray-400">({{ $relativeTs($updatedAt) }} ago)</span>
@endif
</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-gray-500 dark:text-gray-400">Completed</dt>
<dd class="text-right">{{ $formatTs($completedAt) ?? '—' }}</dd>
</div>
</dl>
</div>
</div>
@if ($runUrl)
<div>
<a
href="{{ $runUrl }}"
class="text-sm font-medium text-gray-600 hover:underline dark:text-gray-300"
target="_blank"
rel="noreferrer"
>
Open run in Monitoring (advanced)
</a>
</div>
@endif
@endif
</div>

View File

@ -0,0 +1,6 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
</div>
</div>

View File

@ -0,0 +1,6 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Audit Log is reserved for future work.
</div>
</div>

View File

@ -1,3 +1,36 @@
<x-filament-panels::page>
<x-filament::tabs label="Operations tabs">
<x-filament::tabs.item
:active="$this->activeTab === 'all'"
wire:click="$set('activeTab', 'all')"
>
All
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'active'"
wire:click="$set('activeTab', 'active')"
>
Active
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'succeeded'"
wire:click="$set('activeTab', 'succeeded')"
>
Succeeded
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'partial'"
wire:click="$set('activeTab', 'partial')"
>
Partial
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'failed'"
wire:click="$set('activeTab', 'failed')"
>
Failed
</x-filament::tabs.item>
</x-filament::tabs>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -1,137 +1,3 @@
<x-filament-panels::page>
@php
$context = is_array($this->run->context ?? null) ? $this->run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$failures = is_array($this->run->failure_summary ?? null) ? $this->run->failure_summary : [];
@endphp
<div class="space-y-6">
<x-filament::section heading="Summary">
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700 dark:text-gray-200 md:grid-cols-2">
<div>
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (int) $this->run->getKey() }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Workspace:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) ($this->run->workspace?->name ?? '—') }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Operation:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->type }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Initiator:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->initiator_name }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Status:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->status }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Outcome:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->outcome }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Started:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->started_at?->format('Y-m-d H:i') ?? '—' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->completed_at?->format('Y-m-d H:i') ?? '—' }}</span>
</div>
</div>
</x-filament::section>
<x-filament::section heading="Target scope" :collapsed="false">
@php
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
@endphp
@if ($entraTenantId === null && $entraTenantName === null)
<div class="text-sm text-gray-600 dark:text-gray-300">
No target scope details were recorded for this run.
</div>
@else
<div class="flex flex-col gap-2 text-sm text-gray-700 dark:text-gray-200">
@if ($entraTenantName !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
</div>
@endif
@if ($entraTenantId !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
</div>
@endif
</div>
@endif
</x-filament::section>
<x-filament::section heading="Report">
@if ((string) $this->run->status !== 'completed')
<div class="text-sm text-gray-600 dark:text-gray-300">
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
</div>
@elseif ((string) $this->run->outcome === 'succeeded')
<div class="text-sm text-gray-700 dark:text-gray-200">
No failures were reported.
</div>
@elseif ($failures === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
Report unavailable. The run completed, but no failure details were recorded.
</div>
@else
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
Findings
</div>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
@foreach ($failures as $failure)
@php
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
$message = is_string($message) && $message !== '' ? $message : null;
@endphp
@if ($reasonCode !== null || $message !== null)
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
@if ($reasonCode !== null)
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $reasonCode }}
</div>
@endif
@if ($message !== null)
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
{{ $message }}
</div>
@endif
</li>
@endif
@endforeach
</ul>
</div>
@endif
</x-filament::section>
</div>
{{ $this->infolist }}
</x-filament-panels::page>

View File

@ -0,0 +1,512 @@
@php
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Links\RequiredPermissionsLinks;
$tenant = $this->currentTenant();
$vm = is_array($viewModel ?? null) ? $viewModel : [];
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
$selectedStatus = (string) ($filters['status'] ?? 'missing');
$selectedType = (string) ($filters['type'] ?? 'all');
$searchTerm = (string) ($filters['search'] ?? '');
$featureOptions = collect($featureImpacts)
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
->map(fn (array $impact): string => (string) $impact['feature'])
->filter()
->unique()
->sort()
->values()
->all();
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
$overall = $overview['overall'] ?? null;
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
$copy = is_array($vm['copy'] ?? null) ? $vm['copy'] : [];
$copyApplication = (string) ($copy['application'] ?? '');
$copyDelegated = (string) ($copy['delegated'] ?? '');
$missingApplication = (int) ($counts['missing_application'] ?? 0);
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
$presentCount = (int) ($counts['present'] ?? 0);
$errorCount = (int) ($counts['error'] ?? 0);
$missingTotal = $missingApplication + $missingDelegated + $errorCount;
$requiredTotal = $missingTotal + $presentCount;
$adminConsentUrl = $tenant ? RequiredPermissionsLinks::adminConsentUrl($tenant) : null;
$adminConsentPrimaryUrl = $tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) : RequiredPermissionsLinks::adminConsentGuideUrl();
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
$reRunUrl = $this->reRunVerificationUrl();
@endphp
<x-filament::page>
<div class="space-y-6">
<x-filament::section>
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm text-gray-600 dark:text-gray-300">
Review whats missing for this tenant and copy the missing permissions for admin consent.
</div>
@if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
@endif
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (app)</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (delegated)</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Present</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['present'] ?? 0) }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Errors</div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['error'] ?? 0) }}</div>
</div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
<div class="mt-2 space-y-1">
<div>
<span class="font-medium">Who can fix this?</span>
Global Administrator / Privileged Role Administrator.
</div>
<div>
<span class="font-medium">Primary next step:</span>
<a
href="{{ $adminConsentPrimaryUrl }}"
class="text-primary-600 hover:underline dark:text-primary-400"
target="_blank"
rel="noreferrer"
>
{{ $adminConsentLabel }}
</a>
</div>
@if ($reRunUrl)
<div>
<span class="font-medium">After granting consent:</span>
<a
href="{{ $reRunUrl }}"
class="text-primary-600 hover:underline dark:text-primary-400"
>
Re-run verification
</a>
</div>
@endif
</div>
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button
color="primary"
size="sm"
x-on:click="showCopyApplication = true"
:disabled="$copyApplication === ''"
>
Copy missing application permissions
</x-filament::button>
<x-filament::button
color="gray"
size="sm"
x-on:click="showCopyDelegated = true"
:disabled="$copyDelegated === ''"
>
Copy missing delegated permissions
</x-filament::button>
</div>
</div>
@if (is_array($featureImpacts) && $featureImpacts !== [])
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($featureImpacts as $impact)
@php
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
$featureKey = is_string($featureKey) ? $featureKey : null;
$missingCount = is_array($impact) ? (int) ($impact['missing'] ?? 0) : 0;
$isBlocked = is_array($impact) ? (bool) ($impact['blocked'] ?? false) : false;
if ($featureKey === null) {
continue;
}
$selected = in_array($featureKey, $selectedFeatures, true);
@endphp
<button
type="button"
wire:click="applyFeatureFilter(@js($featureKey))"
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-gray-950 dark:text-white">
{{ $featureKey }}
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
{{ $missingCount }} missing
</div>
</div>
<x-filament::badge :color="$isBlocked ? 'danger' : ($missingCount > 0 ? 'warning' : 'success')" size="sm">
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
</x-filament::badge>
</div>
</button>
@endforeach
</div>
@if ($selectedFeatures !== [])
<div>
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
Clear feature filter
</x-filament::button>
</div>
@endif
@endif
<div
x-cloak
x-show="showCopyApplication"
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
x-on:keydown.escape.window="showCopyApplication = false"
>
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyApplication = false"></div>
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing application permissions</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for admin consent.</div>
</div>
<x-filament::button color="gray" size="sm" x-on:click="showCopyApplication = false">Close</x-filament::button>
</div>
@if ($copyApplication === '')
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
Nothing to copy no missing application permissions in the current feature filter.
</div>
@else
<div
class="mt-4 space-y-2"
x-data="{
text: @js($copyApplication),
copied: false,
async copyPayload() {
try {
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
await navigator.clipboard.writeText(this.text);
} else {
const ta = document.createElement('textarea');
ta.value = this.text;
ta.style.position = 'fixed';
ta.style.inset = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ta.remove();
}
this.copied = true;
setTimeout(() => (this.copied = false), 1500);
} catch (e) {
this.copied = false;
}
},
}"
>
<div class="flex items-center justify-end gap-2">
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
Copied
</span>
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
Copy
</x-filament::button>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
</div>
</div>
@endif
</div>
</div>
<div
x-cloak
x-show="showCopyDelegated"
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
x-on:keydown.escape.window="showCopyDelegated = false"
>
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyDelegated = false"></div>
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing delegated permissions</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for delegated consent.</div>
</div>
<x-filament::button color="gray" size="sm" x-on:click="showCopyDelegated = false">Close</x-filament::button>
</div>
@if ($copyDelegated === '')
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
Nothing to copy no missing delegated permissions in the current feature filter.
</div>
@else
<div
class="mt-4 space-y-2"
x-data="{
text: @js($copyDelegated),
copied: false,
async copyPayload() {
try {
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
await navigator.clipboard.writeText(this.text);
} else {
const ta = document.createElement('textarea');
ta.value = this.text;
ta.style.position = 'fixed';
ta.style.inset = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ta.remove();
}
this.copied = true;
setTimeout(() => (this.copied = false), 1500);
} catch (e) {
this.copied = false;
}
},
}"
>
<div class="flex items-center justify-end gap-2">
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
Copied
</span>
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
Copy
</x-filament::button>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
</div>
</div>
@endif
</div>
</div>
</div>
</x-filament::section>
<x-filament::section heading="Details">
@if (! $tenant)
<div class="text-sm text-gray-600 dark:text-gray-300">
No tenant selected.
</div>
@else
<div class="space-y-6">
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do.
</div>
</div>
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
Reset
</x-filament::button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-4">
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
<select wire:model.live="status" class="fi-input fi-select w-full">
<option value="missing">Missing</option>
<option value="present">Present</option>
<option value="all">All</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
<select wire:model.live="type" class="fi-input fi-select w-full">
<option value="all">All</option>
<option value="application">Application</option>
<option value="delegated">Delegated</option>
</select>
</div>
<div class="space-y-1 sm:col-span-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
<input
type="search"
wire:model.live.debounce.500ms="search"
class="fi-input w-full"
placeholder="Search permission key or description…"
/>
</div>
@if ($featureOptions !== [])
<div class="space-y-1 sm:col-span-4">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
@foreach ($featureOptions as $feature)
<option value="{{ $feature }}">{{ $feature }}</option>
@endforeach
</select>
</div>
@endif
</div>
</div>
@if ($requiredTotal === 0)
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">No permissions configured</div>
<div class="mt-1">
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
</div>
</div>
@elseif ($permissions === [])
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
<div class="mt-1">
Switch Status to “All” if you want to review the full matrix.
</div>
@else
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
<div class="mt-1">
No permissions match the current filters.
</div>
@endif
</div>
@else
@php
$featuresToRender = $featureImpacts;
if ($selectedFeatures !== []) {
$featuresToRender = collect($featureImpacts)
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
->values()
->all();
}
@endphp
@foreach ($featuresToRender as $impact)
@php
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
$featureKey = is_string($featureKey) ? $featureKey : null;
if ($featureKey === null) {
continue;
}
$rows = collect($permissions)
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
->values()
->all();
if ($rows === []) {
continue;
}
@endphp
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $featureKey }}
</div>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Permission
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Type
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
@foreach ($rows as $row)
@php
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
$description = is_array($row) ? ($row['description'] ?? null) : null;
$description = is_string($description) ? $description : null;
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
@endphp
<tr
class="align-top"
data-permission-key="{{ $key }}"
data-permission-type="{{ $type }}"
data-permission-status="{{ $status }}"
>
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $key }}
</div>
@if ($description)
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
{{ $description }}
</div>
@endif
</td>
<td class="px-4 py-3">
<x-filament::badge color="gray" size="sm">
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
</x-filament::badge>
</td>
<td class="px-4 py-3">
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
@endif
</div>
@endif
</x-filament::section>
</div>
</x-filament::page>

View File

@ -0,0 +1,174 @@
@php
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspace(request());
$user = auth()->user();
$canSeeAllWorkspaceTenants = false;
if ($user instanceof User && $workspace) {
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
$canSeeAllWorkspaceTenants = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}
$tenants = collect();
if ($user instanceof User && $workspace) {
if ($canSeeAllWorkspaceTenants) {
$tenants = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->orderBy('name')
->get();
} else {
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->filter(fn ($tenant): bool => $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey())
->values();
}
}
$currentTenant = Filament::getTenant();
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$path = '/'.ltrim(request()->path(), '/');
$isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/');
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
@endphp
<div class="flex items-center gap-3">
<x-filament::dropdown placement="bottom-start" teleport>
<x-slot name="trigger">
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-squares-2x2"
>
{{ $workspace?->name ?? 'Select workspace' }}
</x-filament::button>
</x-slot>
<x-filament::dropdown.list>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Switch workspace
</a>
</x-filament::dropdown.list>
</x-filament::dropdown>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
@if (! $workspace)
<div class="flex items-center gap-2">
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-building-office-2"
disabled
>
Select tenant
</x-filament::button>
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
</div>
@elseif ($isTenantScopedRoute)
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-building-office-2"
disabled
>
{{ $currentTenantName ?? 'Tenant' }}
</x-filament::button>
@else
<x-filament::dropdown placement="bottom-start" teleport>
<x-slot name="trigger">
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-building-office-2"
>
{{ $currentTenantName ?? 'Select tenant' }}
</x-filament::button>
</x-slot>
<x-filament::dropdown.list>
<div class="px-3 py-2 space-y-2" x-data="{ query: '' }">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
Tenant context
@if ($canSeeAllWorkspaceTenants)
<span class="text-gray-400">· all workspace tenants</span>
@endif
</div>
@if ($tenants->isEmpty())
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }}
</div>
@else
<div class="space-y-2">
<input
type="text"
class="fi-input fi-text-input w-full"
placeholder="Search tenants…"
x-model="query"
/>
<div class="max-h-64 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
@foreach ($tenants as $tenant)
<form method="POST" action="{{ route('admin.select-tenant') }}">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
<button
type="submit"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
>
{{ $tenant->getFilamentName() }}
</button>
</form>
@endforeach
</div>
@if ($canClearTenantContext)
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<x-filament::button color="gray" size="sm" outlined>
Clear tenant context
</x-filament::button>
</form>
@endif
<div class="text-xs text-gray-500 dark:text-gray-400">
Switching tenants is explicit. Canonical monitoring URLs do not change tenant context.
</div>
</div>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
@endif
</div>

View File

@ -25,7 +25,7 @@
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
@csrf
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Switch workspace</div>
<select
name="workspace_id"
@ -40,7 +40,7 @@ class="fi-input fi-select w-full"
@endforeach
</select>
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Select a workspace to switch context.</div>
</form>
</div>
</x-filament::dropdown.list>

View File

@ -0,0 +1,55 @@
@php
/** @var ?\App\Models\Tenant $tenant */
/** @var \Illuminate\Support\Collection<int, \App\Models\OperationRun> $runs */
/** @var string $operationsIndexUrl */
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold">Recent operations</div>
<a
href="{{ $operationsIndexUrl }}"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
View all operations
</a>
</div>
@if ($runs->isEmpty())
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
No operations yet.
</div>
@else
<ul class="mt-3 divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($runs as $run)
<li class="flex items-center justify-between gap-3 py-2">
<div class="min-w-0">
<div class="truncate text-sm font-medium">
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $run->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
<div class="flex shrink-0 items-center gap-3">
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
<div>{{ (string) $run->status }}</div>
<div>{{ (string) $run->outcome }}</div>
</div>
<a
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
View
</a>
</div>
</li>
@endforeach
</ul>
@endif
</div>

View File

@ -1,60 +0,0 @@
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Summary</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt class="text-gray-600">Type:</dt>
<dd>{{ $run->type }}</dd>
<dt class="text-gray-600">Status:</dt>
<dd>{{ $run->status }}</dd>
<dt class="text-gray-600">Outcome:</dt>
<dd>{{ $run->outcome }}</dd>
<dt class="text-gray-600">Initiator:</dt>
<dd>{{ $run->initiator_name }}</dd>
</dl>
</div>
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Timing</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt class="text-gray-600">Created:</dt>
<dd>{{ $run->created_at }}</dd>
<dt class="text-gray-600">Started:</dt>
<dd>{{ $run->started_at ?? '-' }}</dd>
<dt class="text-gray-600">Completed:</dt>
<dd>{{ $run->completed_at ?? '-' }}</dd>
</dl>
</div>
</div>
@if(!empty($run->summary_counts))
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Counts</h3>
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
@if(!empty($run->failure_summary))
<div class="bg-white p-4 rounded shadow border-l-4 border-red-500">
<h3 class="font-bold text-lg mb-2 text-red-700">Failures</h3>
<div class="space-y-2">
@foreach($run->failure_summary as $failure)
<div class="bg-red-50 p-2 rounded">
<div class="font-mono text-xs text-red-800">{{ $failure['code'] ?? 'UNKNOWN' }}</div>
<div class="text-sm text-red-900">{{ $failure['message'] ?? 'Unknown error' }}</div>
</div>
@endforeach
</div>
</div>
@endif
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Context</h3>
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->context, JSON_PRETTY_PRINT) }}</pre>
</div>
</div>

View File

@ -3,13 +3,13 @@
use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\SelectTenantController;
use App\Http\Controllers\SwitchWorkspaceController;
use App\Http\Controllers\TenantOnboardingController;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
@ -34,7 +34,6 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
@ -73,7 +72,7 @@
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
}
@ -101,6 +100,10 @@
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
->post('/admin/select-tenant', SelectTenantController::class)
->name('admin.select-tenant');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
->post('/admin/clear-tenant-context', ClearTenantContextController::class)
->name('admin.clear-tenant-context');
Route::bind('workspace', function (string $value): Workspace {
/** @var WorkspaceResolver $resolver */
$resolver = app(WorkspaceResolver::class);
@ -136,10 +139,46 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
->name('admin.monitoring.alerts');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
->name('admin.monitoring.audit-log');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->name('admin.operations.view');
@ -148,12 +187,10 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
'ensure-filament-tenant-selected',
])
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
->name('admin.workspace.managed-tenants.index');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Workspace-first Navigation & Monitoring Hub
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-06
**Feature**: [specs/077-workspace-nav-monitoring-hub/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass on first iteration.
- URLs are treated as product behavior (not implementation details).

View File

@ -0,0 +1,69 @@
# Contracts — Routes & Semantics (077)
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](../spec.md)
This feature is an admin UI/navigation refactor. Contracts are expressed as web route semantics + access rules.
## Canonical routes
### Workspace context
- `GET /admin/choose-workspace`
- Purpose: select active workspace context
- Access: authenticated user
- Visibility: shows only workspaces where the user is a member
- `POST /admin/switch-workspace`
- Purpose: update workspace context
- Access: authenticated user
- Security:
- If user is not a member of the selected workspace → 404 (deny-as-not-found)
### Workspace management (CRUD)
- `GET /admin/workspaces`
- `GET /admin/workspaces/{workspace}`
- `GET /admin/workspaces/{workspace}/edit`
- `GET /admin/workspaces/create`
Contract semantics:
- Workspace context is optional on `/admin/workspaces` (Global Mode).
- Index lists only workspaces the user is a member of.
- If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found)
- Workspace creation is self-serve for authenticated users (policy-driven).
- If user is a member but lacks the required capability for a protected action/screen (edit/membership management) → 403
- If user is authorized → normal Filament behavior
### Monitoring hub — Operations
- `GET /admin/operations`
- Canonical operations index (tenantless URL)
- Behavior:
- If tenant context is active: default filter state = current tenant (removable)
- If tenant context is not active: workspace-wide list
- `GET /admin/operations/{run}`
- Canonical run deep link
- Security:
- If run belongs to a workspace the user is not a member of → 404
### Monitoring hub — Reserved surfaces (placeholders)
- `GET /admin/alerts`
- Reserved placeholder page
- Access: workspace members (workspace context required)
- `GET /admin/audit-log`
- Reserved placeholder page
- Access: workspace members (workspace context required)
## Status code rules (summary)
- Non-member / not entitled to the workspace scope → 404
- Member but missing capability (workspace-scoped protected actions) → 403
## Non-leakage requirements
- Global search must not list inaccessible workspaces/tenants/runs.
- Navigation labels and groups must not imply the existence of admin-only surfaces.

View File

@ -0,0 +1,66 @@
# Data Model — Workspace-first Navigation & Monitoring Hub (077)
**Date**: 2026-02-06
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
This feature is primarily information architecture + context enforcement. No new tables are required; the design depends on existing entities and their relationships.
## Entities
### Workspace
Represents a portfolio / customer container (primary context).
- Key fields (existing, relevant):
- `id`
- `name`
- `slug` (optional)
- `archived_at` (nullable)
### WorkspaceMembership
Entitlement relationship between a user and a workspace.
- Key fields (existing, relevant):
- `workspace_id`
- `user_id`
- `role` (e.g. owner/operator/etc; actual role semantics are managed by the capability resolver)
### Tenant (Managed Tenant)
Represents a Microsoft/Intune tenant belonging to a workspace (secondary context via Filament tenancy).
- Key fields (existing, relevant):
- `id`
- `workspace_id` (foreign key to Workspace)
- `external_id` (used in Filament tenancy route `/admin/t/{tenant}`)
- `status` (e.g., active)
### OperationRun
Canonical monitoring record (workspace-level entity; may optionally be linked to a tenant).
- Key fields (existing, relevant):
- `id`
- `workspace_id` (required for access control)
- `tenant_id` (nullable; used for default filtering and “recent operations”)
- `type`, `status`, `outcome`
- timestamps (created/started/completed)
- `context` (JSON)
## Relationships
- Workspace has many WorkspaceMemberships.
- Workspace has many Tenants.
- Workspace has many OperationRuns.
- Tenant belongs to Workspace.
- OperationRun belongs to Workspace.
- OperationRun optionally belongs to Tenant.
## Invariants / Rules enforced by this feature
- Workspace context (`current_workspace_id`) is required for workspace-scoped navigation and access control.
- Tenant context must be consistent with workspace context:
- If tenant is not in current workspace, tenant context is cleared (continue tenantless).
- OperationRun access is controlled by membership in the runs `workspace_id`.

View File

@ -0,0 +1,215 @@
# Implementation Plan: Workspace-first Navigation & Monitoring Hub
**Branch**: `077-workspace-nav-monitoring-hub` | **Date**: 2026-02-06 | **Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
**Input**: Feature specification from [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Resolve workspace navigation ambiguity and formalize a workspace-first context model:
- Unambiguous labels: **Switch workspace** (`/admin/choose-workspace`) vs **Manage workspaces** (`/admin/workspaces`).
- Monitoring → **Operations** remains canonical and tenantless (`/admin/operations`, `/admin/operations/{run}`).
- Tenant context influences Operations only via **server-side default filter state** (removable), never via routing.
- Strict non-leaking security semantics:
- Non-member workspace scope → 404 (deny-as-not-found)
- Workspace member missing capability (protected actions/screens) → 403
- Accessing a workspace record outside membership → 404 (deny-as-not-found)
Supporting artifacts:
- [research.md](research.md)
- [data-model.md](data-model.md)
- [contracts/routes.md](contracts/routes.md)
- [quickstart.md](quickstart.md)
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4
**Target Platform**: Web (Filament admin panels)
**Project Type**: Laravel monolith
**Performance Goals**: Operations pages remain DB-only at render; list/detail stay fast on large run tables (pagination + indexed filters)
**Constraints**: Filament-native patterns only; canonical URLs must not depend on tenant context; strict 404/403 non-leakage semantics
**Scale/Scope**: Multi-workspace MSP use; many tenants and many operation runs
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: N/A (no inventory semantics changes)
- Read/write separation: PASS (no write operations introduced)
- Graph contract path: N/A (no Graph calls)
- Deterministic capabilities: PASS (capability gating uses existing resolver/registry patterns)
- RBAC-UX: PASS (explicit 404 vs 403 rules)
- RBAC-UX destructive confirmation: N/A (no destructive actions introduced)
- RBAC-UX global search: N/A (no new searchable resources; no changes to global search)
- Tenant isolation: PASS (workspace membership is isolation boundary; tenant context auto-cleared when invalid)
- Run observability: N/A (no new operations/jobs)
- Automation: N/A
- Data minimization: N/A
- Badge semantics (BADGE-001): N/A
## Project Structure
### Documentation (this feature)
```text
specs/077-workspace-nav-monitoring-hub/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── routes.md
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── ChooseWorkspace.php
│ └── Resources/
│ ├── OperationRunResource.php
│ └── OperationRunResource/
│ └── Pages/
│ └── ListOperationRuns.php
├── Http/
│ └── Middleware/
│ └── EnsureWorkspaceSelected.php
├── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php
└── Support/
└── Middleware/
└── EnsureFilamentTenantSelected.php
resources/
└── views/
└── filament/
└── partials/
└── workspace-switcher.blade.php
routes/
└── web.php
tests/
└── Feature/
└── (new tests for navigation labels + 404/403 + operations default filter)
```
**Structure Decision**: Laravel monolith using Filament resources/pages and Laravel middleware.
## Complexity Tracking
No constitution violations.
## Phase 0 — Outline & Research (complete)
All unknowns/decisions have been resolved and recorded:
- Repo reality + ambiguity sources + decisions D1D4: [research.md](research.md)
- No remaining NEEDS CLARIFICATION items in the spec.
## Phase 1 — Design & Contracts (complete)
- Data model: no new tables/columns required; behavior is implemented via middleware + Filament config: [data-model.md](data-model.md)
- Route/security contracts: [contracts/routes.md](contracts/routes.md)
- Manual validation steps + suggested test filters: [quickstart.md](quickstart.md)
## Phase 2 — Implementation Plan (ready for tasks)
### Step 1 — Navigation labels: “one label, one meaning”
- Update admin navigation to include:
- **Switch workspace** (topbar context switcher) → `/admin/choose-workspace`
- **Manage workspaces** (sidebar Settings) → `/admin/workspaces`
- Remove/replace any navigation items labeled only “Workspaces”.
Implementation targets:
- Update [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) navigation builder:
- Change the label from `Workspaces` to `Switch workspace` for the choose-workspace link.
- Ensure this fallback navigation does not accidentally imply CRUD management.
- Update [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php) nav item label for workspace CRUD to `Manage workspaces`.
- Update [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php) text/links to consistently say “Switch workspace”.
- Add reserved Monitoring navigation surfaces for **Alerts** and **Audit Log** as placeholder pages (non-functional “coming soon”) to satisfy FR-011.
### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
- `/admin/workspaces` stays tenantless and is **Global Mode** (workspace-optional).
- Enforce strict non-leakage semantics:
- Non-member attempting to access a workspace record → **404** (deny-as-not-found)
- Member missing required capability for protected actions/screens → **403**
Implementation targets:
- Scope the Workspaces query (index) to only workspaces the user is a member of.
- Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access).
- Workspace creation is self-serve (policy-driven). Gate edit/membership-management behind canonical workspace capabilities (no raw strings).
- Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based).
### Step 3 — Workspace selection redirect + return-to-intended
Requirement: visiting any workspace-scoped page without a selected workspace MUST redirect to `/admin/choose-workspace` and then return to the originally requested URL.
Implementation targets:
- Update [app/Http/Middleware/EnsureWorkspaceSelected.php](../../app/Http/Middleware/EnsureWorkspaceSelected.php):
- When redirecting to `/admin/choose-workspace`, store the intended URL (path + query) in session.
- Preserve the existing exemptions for auth routes and for `/admin/operations/{run}` and Livewire update referers.
- Update both workspace-selection entrypoints to honor intended URLs:
- [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
- [app/Http/Controllers/SwitchWorkspaceController.php](../../app/Http/Controllers/SwitchWorkspaceController.php)
- After setting the workspace, redirect to the stored intended URL (if present and safe), otherwise keep the existing behavior (onboarding / choose-tenant / tenant dashboard).
### Step 4 — Auto-clear invalid tenant context on workspace change
Requirement: if tenant context is active but does not belong to the current workspace, auto-clear tenant context and continue on tenantless workspace pages.
Implementation targets:
- In [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) (or a dedicated middleware used for tenantless pages):
- Detect a persisted Filament tenant that does not match `WorkspaceContext::currentWorkspaceId()`.
- Clear the persisted Filament tenant context (confirm the correct Filament v5 mechanism during implementation).
### Step 5 — Operations: move tenant scoping from query to removable default filter
Requirement: `/admin/operations` stays canonical; if tenant context is active, default to that tenant using server-side default filter state with a visible removable chip.
Implementation targets:
- Update [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php):
- Remove tenant-context filtering from `getEloquentQuery()`.
- Update [app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php](../../app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php):
- Add a tenant filter (select) over available tenants in the current workspace.
- Default the filter state from the current tenant context when valid.
- Ensure the filter chip is visible and can be cleared to view workspace-wide operations.
### Step 6 — Tests (Pest) + formatting
Add/adjust tests to cover the strict semantics:
- Navigation labels: “Switch workspace” vs “Manage workspaces” (no ambiguous “Workspaces”).
- `/admin/workspaces`:
- non-member record access → 404
- member missing capability for a protected action/screen → 403
- EnsureWorkspaceSelected:
- visiting `/admin/operations` without workspace → redirects to choose-workspace
- after selecting workspace → returns to intended URL
- Operations default filter:
- with tenant context active → tenant filter default set
- clearing filter → shows workspace-wide results
Tooling:
- Run `./vendor/bin/sail bin pint --dirty`.
- Run focused tests via `./vendor/bin/sail artisan test --compact --filter=...`.

View File

@ -0,0 +1,50 @@
# Quickstart — Workspace-first Navigation & Monitoring Hub (077)
**Audience**: Devs and reviewers validating the feature on staging/local
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
## Local setup
- Start containers: `./vendor/bin/sail up -d`
- Install dependencies if needed: `./vendor/bin/sail composer install` and `./vendor/bin/sail npm install`
- Run migrations: `./vendor/bin/sail artisan migrate`
## Manual validation checklist
### Navigation separation
1. Open `/admin` and sign in.
2. In the user menu, confirm there is an explicit entry labeled **"Switch workspace"** that navigates to `/admin/choose-workspace`.
3. In the sidebar, confirm **"Manage workspaces"** exists only when authorized.
4. Confirm there is no navigation item labeled simply **"Workspaces"** that ambiguously points to both concepts.
### Operations canonical + default tenant filter
1. Visit `/admin/operations` with no tenant context selected.
- Expect: page loads and shows workspace-wide runs.
2. Activate tenant context (`/admin/t/{tenant}`), then navigate to `/admin/operations`.
- Expect: default tenant filter applied, visible filter chip, chip can be cleared.
3. Visit a run deep link `/admin/operations/{run}` from both tenantless and tenant context.
- Expect: same canonical page, no tenant-route dependency.
### Security semantics
- Non-member accessing operations for another workspace: expect **404**.
- Workspace member but missing capability for a protected action/screen: expect **403**.
- Accessing `/admin/workspaces` for a workspace you are not a member of: expect **404**.
## Test execution
Run focused tests:
- US1 (nav separation): `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`
- US2 (canonical ops URLs): `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls`
- US3 (non-leakage): `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
Run a targeted suite for the feature area:
- `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
Run formatting before finalizing:
- `./vendor/bin/sail pint --dirty`

View File

@ -0,0 +1,58 @@
# Research — Workspace-first Navigation & Monitoring Hub (077)
**Date**: 2026-02-06
**Branch**: 077-workspace-nav-monitoring-hub
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
## Repo Reality Check (what exists today)
- Admin panel exists at `/admin` via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
- System panel exists at `/system` with a separate auth guard (`platform`) via [app/Providers/Filament/SystemPanelProvider.php](../../app/Providers/Filament/SystemPanelProvider.php).
- Workspace context selection exists:
- Page `/admin/choose-workspace` via [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
- POST switch endpoint `/admin/switch-workspace` via [routes/web.php](../../routes/web.php)
- Workspace switcher UI in the user menu via [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php)
- Navigation ambiguity is currently real:
- When no tenant is selected, navigation is replaced with a single item labeled **"Workspaces"** linking to the choose-workspace page via [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php).
- Separately, the sidebar includes another **"Workspaces"** item linking to `/admin/workspaces` (workspace CRUD) via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
- Operations is already canonical and tenantless:
- Resource slug is `/admin/operations` via [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php).
- Detail page is `/admin/operations/{record}`.
## Decisions (resolved)
### D1 — Manage workspaces stays on `/admin/workspaces` and follows workspace RBAC semantics (404 for non-members, 403 for missing capability)
- Decision: Treat `/admin/workspaces` as a **workspace-scoped** management surface in the tenant plane (`/admin`, Entra users):
- Non-members (or out-of-scope workspace records) → **404** (deny-as-not-found)
- Members missing required capabilities for protected actions/screens → **403**
- Rationale: Aligns with the constitution RBAC-UX model (membership is the isolation boundary; capability denial is 403 after membership is established) while still preventing cross-workspace leakage.
- Alternatives considered:
- Move management into `/system` panel: rejected because this feature targets the tenant plane IA. (If workspace CRUD becomes platform-admin only later, that should be handled as a separate migration spec.)
### D2 — Tenant context influences Operations via server-side default filter state, not querystring
- Decision: Apply the tenant default filter server-side while keeping the canonical URL `/admin/operations` unchanged.
- Rationale: Matches Spec 077 clarification (Q2=A). Prevents link-sharing surprises and keeps canonical monitoring routes stable.
- Alternatives considered:
- Querystring-based default filtering (e.g. `?tenant_id=`): rejected as it makes filtered URLs the de-facto navigation target.
### D3 — Missing workspace context redirects to `/admin/choose-workspace` and returns to the requested URL
- Decision: When a workspace-scoped page is visited without an active workspace selection, redirect to `/admin/choose-workspace` and then return.
- Rationale: Matches Spec 077 clarification (Q3=A) and aligns with existing `/admin` root override behavior in [routes/web.php](../../routes/web.php).
### D4 — Invalid tenant context (tenant not in current workspace) is auto-cleared
- Decision: If tenant context is active but does not belong to the current workspace, clear tenant context and continue on workspace-level pages.
- Rationale: Matches Spec 077 clarification (Q4=A). Reduces “ghost tenant” behavior after a workspace switch.
- Alternatives considered:
- Hard 404: rejected as too confusing during normal context switching.
## Key Implementation Implications (for planning)
- **Rename navigation labels** to satisfy “one label, one meaning”:
- The “Workspaces” navigation item that points to the choose-workspace page must become **"Switch workspace"**.
- The “Workspaces” navigation item that points to CRUD must become **"Manage workspaces"** and be capability-gated with workspace RBAC semantics (404 for non-members; 403 for missing capability).
- **Operations filter chip/removal**: current behavior filters by `Tenant::current()` inside `OperationRunResource::getEloquentQuery()`, which is not user-removable. The plan should move this behavior into a table filter with default state.
- **No render-time external calls**: monitoring pages must remain DB-only at render (already consistent with constitution).

View File

@ -0,0 +1,164 @@
# Feature Specification: Workspace-first Navigation & Monitoring Hub
**Feature Branch**: `077-workspace-nav-monitoring-hub`
**Created**: 2026-02-06
**Status**: Implemented
**Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage."
## Clarifications
### Session 2026-02-06
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). `/admin/workspaces` is **Global Mode** (workspace-optional). Index lists only the users workspaces; per-record access for non-members is 404 (deny-as-not-found); protected actions/screens return 403 when unauthorized.
- Q: Should `/admin/workspaces` require an active `current_workspace_id`? → A: No. `/admin/workspaces` is **Global Mode** (workspace-optional). The index lists only workspaces the user is a member of; per-record access for non-members remains 404.
- Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`.
- Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection.
- Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Switch workspace without ambiguity (Priority: P1)
As an operator/admin, I need to switch my active workspace (portfolio) using a clear, single-purpose entry point, so that I never confuse "switch workspace" with "manage workspaces".
**Why this priority**: Workspace context is foundational. If its confusing, every other module becomes harder to use and support.
**Independent Test**: A user can find "Switch workspace", select a workspace they are a member of, and the application context updates while workspace management remains separate.
**Acceptance Scenarios**:
1. **Given** I am signed in and belong to multiple workspaces, **When** I choose "Switch workspace", **Then** I see only workspaces I am a member of and can select one.
2. **Given** I can manage workspaces, **When** I open "Manage workspaces", **Then** I can access workspace CRUD screens and breadcrumbs stay within the management area.
3. **Given** I cannot manage workspaces, **When** I look at navigation, **Then** I do not see "Manage workspaces".
---
### User Story 2 - Use Monitoring hub from canonical links (Priority: P2)
As an operator, I need monitoring pages (starting with Operations) to be reachable via stable, shareable links that never depend on tenant context, so that support, alerts, and notifications can deep-link reliably.
**Why this priority**: Monitoring must be dependable across contexts; deep links are critical for incident response and support.
**Independent Test**: Visiting the canonical operations URLs works with and without tenant context, and the system enforces membership checks.
**Acceptance Scenarios**:
1. **Given** I am a member of a workspace, **When** I visit `/admin/operations`, **Then** I can view a workspace-wide list of operations.
2. **Given** I have an active tenant context, **When** I visit `/admin/operations`, **Then** operations are pre-filtered to that tenant but the URL remains `/admin/operations`.
3. **Given** I have a run link `/admin/operations/{run}`, **When** I open it, **Then** I see the run detail regardless of tenant context.
---
### User Story 3 - Navigate and search without leaking inaccessible data (Priority: P3)
As a user, I should never learn about workspaces/tenants/runs I cannot access through navigation labels, breadcrumbs, counts, or global search results.
**Why this priority**: Preventing information leakage is a core enterprise requirement and reduces risk in multi-tenant MSP environments.
**Independent Test**: An unauthorized user receives not-found responses for out-of-scope resources and does not see them in search.
**Acceptance Scenarios**:
1. **Given** I am not a member of a workspace, **When** I attempt to access that workspaces monitoring data or runs, **Then** I receive a not-found response.
2. **Given** I am a workspace member but lack a capability for a protected workspace-scoped screen or action, **When** I attempt to access it directly, **Then** I receive a forbidden response.
3. **Given** I use global search, **When** I search for entities outside my scope, **Then** they do not appear in results (no partial hints).
### Edge Cases
- User is a member of zero workspaces.
- User loses workspace membership while having an active session.
- Tenant context is active but the tenant does not belong to the current workspace.
- A run is referenced by an external deep link after it was deleted or moved.
- User can view monitoring but cannot perform mutations (e.g., cancel/retry) if those actions exist.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes navigation and authorization behavior but does not introduce new external API calls or background jobs. Any mutation actions added later (e.g., cancel/retry) must follow the platforms safety gates (confirmation/audit) and be covered by authorization tests.
**Constitution alignment (RBAC-UX):**
- **Authorization plane(s) involved**:
- **Tenant plane (Entra users)** only.
- **Platform plane (`/system`) is out of scope** for this feature.
- **Authorization planes**:
- Workspace-level pages (e.g., monitoring hub, workspace management) are governed by workspace membership and workspace capabilities.
- Tenant context is secondary and must not change canonical routing for monitoring pages.
- **Isolation model note (workspace scope)**:
- “Workspace-scoped” monitoring is an explicit, access-checked aggregation scope over the managed tenants that belong to the selected workspace.
- All reads remain bounded to the current workspace; there is no cross-workspace monitoring view in this feature.
- **404 vs 403 semantics (strict)**:
- Non-member / not entitled to the workspace scope → **404** (deny-as-not-found)
- Workspace member but missing the required capability for a protected screen/action → **403**
- **Server-side enforcement**: Navigation visibility must not be treated as authorization; all access control is enforced on the server for every protected page and every mutation.
- **Global search non-leakage**: Global search must not show titles, counts, or partial matches for inaccessible workspaces/tenants/runs. Inaccessible entities behave as not-found.
### Functional Requirements
- **FR-001 (One label, one meaning)**: The application MUST provide two distinct, clearly-labeled entry points:
- "Switch workspace" for selecting the active workspace context.
- "Manage workspaces" for workspace CRUD/administration.
- **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`.
- **UX note**: "Switch workspace" is a global context control and MUST NOT be registered as a sidebar navigation item.
- **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces".
- **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher.
- **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context.
- **FR-006 (Canonical Operations URLs)**: Operations MUST remain canonical and tenantless:
- index: `/admin/operations`
- detail: `/admin/operations/{run}`
- **FR-007 (Tenant context affects defaults, not routing)**: If tenant context is active, the operations index MUST default to showing runs for that tenant using **server-side default filter state**, and users MUST be able to clear that default to view workspace-wide operations. The canonical URL MUST remain `/admin/operations` and the default MUST present a visible, removable filter chip (no required querystring parameters).
- **FR-008 (Tenant shortcut to operations)**: Tenant detail screens MUST offer a "Recent operations" summary and a "View all operations" call-to-action that leads to the canonical operations index.
- **FR-009 (Membership gating)**: Users MUST be a member of a workspace to access workspace-scoped pages. Non-members MUST receive a not-found response.
- **FR-010 (Capability gating for management)**: Workspace-scoped management/mutations MUST be restricted to users with the appropriate capability/capabilities (from the canonical registry). Unauthorized workspace members MUST receive a forbidden response.
- Canonical capabilities used by this feature:
- `workspace.manage` (Capabilities::WORKSPACE_MANAGE): create/edit workspace fields.
- `workspace_membership.manage` (Capabilities::WORKSPACE_MEMBERSHIP_MANAGE): add/remove members and change roles.
- **FR-011 (Monitoring hub IA)**: The sidebar MUST provide a "Monitoring" area that is the canonical home for Operations now, with reserved surfaces for future Alerts and Audit Log.
- **FR-012 (Deep-link stability)**: Any monitoring entity intended for support workflows MUST have a stable deep link that does not depend on tenant context.
- **FR-013 (No workspace selected)**: If a user visits a workspace-scoped page without a selected workspace context, the system MUST redirect to `/admin/choose-workspace` and then return the user to their originally requested URL after a successful selection.
- **FR-014 (Invalid tenant context)**: If tenant context is active but the tenant does not belong to the current workspace, the system MUST auto-clear tenant context and continue on workspace-level pages without tenant scoping.
- **FR-077-016 (Header context bar)**: The header MUST provide an always-available context bar for Suite navigation:
- **FR-077-016-A (Workspace visible)**: If a workspace is selected, show `Workspace: <name>` and allow the user to open the existing workspace switcher (`/admin/choose-workspace`).
- **FR-077-016-B (Tenant accessible on tenantless pages)**: The header MUST surface tenant context even on tenantless pages (e.g., `/admin/operations`). If there is an active tenant context, show `Tenant: <tenant name>` (fallback to a safe identifier). If there is no active tenant but there is a last-selected tenant in the current workspace session, show it.
- **FR-077-016-C (No implicit switching)**: Canonical pages MUST NOT silently switch tenant or workspace. The context bar is an explicit control only.
- **FR-077-016-D (No leakage)**: Tenant picker contents MUST include only tenants the user is entitled to view within the current workspace. Unauthorized tenant selection via direct URL MUST remain deny-as-not-found (404).
- **FR-077-016-E (Filament-native)**: Implementation MUST use Filament v5 mechanisms (topbar/user-menu render hooks + Filament tenancy) and Livewire v4 where needed.
### Key Entities *(include if feature involves data)*
- **Workspace**: Primary context container for a customer/portfolio.
- **Workspace Membership**: The relationship that entitles a user to a workspace.
- **Managed Tenant**: Secondary context within a workspace; used for scoping defaults and tenant workflows.
- **Operation Run**: A record representing an operational execution that belongs to a workspace and may be associated with a tenant.
- **Capability**: A named permission that gates management/mutation behavior.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Reduced confusion)**: In a moderated test with new users, at least 90% correctly choose the right destination (switch vs manage) on first attempt.
- **SC-002 (Faster workspace switching)**: Users can switch to a known workspace in under 15 seconds without using search.
- **SC-003 (Reliable deep links)**: Support can open `/admin/operations/{run}` successfully regardless of tenant context in 100% of tested cases.
- **SC-004 (No leakage regressions)**: Security regression tests confirm 0 instances of inaccessible workspaces/tenants/runs appearing in navigation or global search.
## Acceptance details (pinned)
### Recent operations summary (FR-008)
- Show the most recent **5** operation runs for the current tenant, ordered by `created_at` descending (fallback: `id` descending).
- Display, at minimum: `type` (label), `status`, `outcome`, `created_at` (or since), and a link to the run detail.
- Provide a "View all operations" CTA that navigates to canonical `/admin/operations` (no tenant prefix / no required query params).
### Header context bar (FR-077-016)
- The header shows a stable, compact context bar:
- `Workspace: <name>` (clickable)
- `Tenant: <name>` (picker)
- Tenant picker is available on tenantless pages.
- No automatic tenant selection occurs when opening canonical URLs.
## Mandatory Tests (pinned)
- **T-077-016-1 (Tenant dropdown on tenantless pages)**: With a selected workspace and an active tenant context, visiting `/admin/operations` shows the tenant picker and selecting a tenant navigates to tenant home.
- **T-077-016-2 (Security filtering)**: Only entitled tenants within the current workspace appear in the picker; posting /navigating to an unauthorized tenant results in 404.
- **T-077-016-3 (No implicit switching)**: Visiting `/admin/operations/{run}` from a deep link MUST NOT auto-switch tenant.

View File

@ -0,0 +1,220 @@
---
description: "Task list for Spec 077 implementation"
---
# Tasks: Workspace-first Navigation & Monitoring Hub (077)
**Input**: Design documents from `/specs/077-workspace-nav-monitoring-hub/`
**Prerequisites**:
- Required: [spec.md](spec.md), [plan.md](plan.md)
- Optional (used): [research.md](research.md), [data-model.md](data-model.md), [contracts/routes.md](contracts/routes.md), [quickstart.md](quickstart.md)
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (navigation + authorization + filtering).
**Livewire/Filament compatibility**: Filament v5 + Livewire v4 only.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare the minimal scaffolding for safe, test-first delivery.
- [X] T001 Create new Pest test file for workspace navigation in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T002 Create new Pest test file for operations canonical routing in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T003 [P] Create new Pest test file for non-leakage semantics in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared plumbing needed by multiple stories.
- [X] T004 Add intended-URL session key constant in app/Support/Workspaces/WorkspaceContext.php
- [X] T005 Implement “store intended URL” helper in app/Support/Workspaces/WorkspaceIntendedUrl.php
- [X] T006 [P] Add tests for intended-URL helper in tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
- [X] T007 Update middleware to use intended-URL helper in app/Http/Middleware/EnsureWorkspaceSelected.php
- [X] T008 [P] Add safe-redirect allowlist for intended URLs in app/Support/Workspaces/WorkspaceIntendedUrl.php
**Checkpoint**: Intended redirect plumbing exists and is covered by tests.
---
## Phase 3: User Story 1 — Switch workspace without ambiguity (Priority: P1) 🎯 MVP
**Goal**: Clear separation between “Switch workspace” and “Manage workspaces”, with correct 404/403 behavior.
**Independent Test**: A signed-in user can switch workspaces via “Switch workspace”, and “Manage workspaces” is only visible/accessible when authorized.
### Tests for User Story 1 (write first)
- [X] T009 [P] [US1] Assert nav label “Switch workspace” appears when tenant is not selected in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T010 [P] [US1] Assert no ambiguous “Workspaces” nav item exists in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T011 [P] [US1] Assert `/admin/workspaces` is tenantless and reachable for a workspace owner in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
- [X] T012 [P] [US1] Assert `/admin/workspaces/{record}` is deny-as-not-found for non-members (404) in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
### Implementation for User Story 1
- [X] T013 [US1] Rename fallback nav item to “Switch workspace” in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T014 [US1] Update user-menu copy/CTA to “Switch workspace” in resources/views/filament/partials/workspace-switcher.blade.php
- [X] T015 [US1] Rename admin sidebar item to “Manage workspaces” in app/Providers/Filament/AdminPanelProvider.php
- [X] T016 [US1] Gate “Manage workspaces” navigation visibility via capability in app/Providers/Filament/AdminPanelProvider.php
- [X] T017 [US1] Enforce workspace-scoped RBAC semantics for workspace management (404 non-member, 403 missing capability) in app/Policies/WorkspacePolicy.php
- [X] T018 [US1] Ensure workspace management breadcrumbs point to `/admin/workspaces` in app/Filament/Resources/Workspaces/WorkspaceResource.php
- [X] T019 [US1] Ensure `/admin/workspaces` routes do not require tenant context in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T020 [US1] Run focused tests for US1 via `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub` (document in specs/077-workspace-nav-monitoring-hub/quickstart.md)
**Checkpoint**: UI uses unambiguous labels; `/admin/workspaces` follows workspace RBAC semantics (no leakage).
---
## Phase 4: User Story 2 — Use Monitoring hub from canonical links (Priority: P2)
**Goal**: `/admin/operations` and `/admin/operations/{run}` work regardless of tenant context; tenant context only sets removable default filters.
**Independent Test**: Visiting `/admin/operations` works tenantless (workspace-selected), and in tenant context it defaults to that tenant via a removable filter chip.
### Tests for User Story 2 (write first)
- [X] T021 [P] [US2] Assert `/admin/operations` is reachable without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T022 [P] [US2] Assert `/admin/operations/{run}` works with and without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T023 [P] [US2] Assert operations list defaults to current tenant (filter state) when tenant context active in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T024 [P] [US2] Assert clearing tenant filter shows workspace-wide runs in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
### Implementation for User Story 2
- [X] T025 [US2] Allow `/admin/operations` (index) through tenancy-enforcing middleware without auto-setting tenant in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T026 [US2] Ensure workspace selection is required for `/admin/operations` and stores intended URL for return flow in app/Http/Middleware/EnsureWorkspaceSelected.php
- [X] T027 [US2] Redirect back to intended URL after workspace selection in both app/Filament/Pages/ChooseWorkspace.php and app/Http/Controllers/SwitchWorkspaceController.php
- [X] T028 [US2] Remove hard tenant scoping from query in app/Filament/Resources/OperationRunResource.php
- [X] T029 [US2] Add tenant SelectFilter with removable chip and server-side default state in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
- [X] T030 [US2] Scope selectable tenants in the filter to current workspace in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
- [X] T031 [US2] Add “Recent operations” summary (last 5 by created_at) + “View all operations” CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [X] T032 [US2] Ensure “View all operations” CTA routes to canonical `/admin/operations` in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [X] T033 [US2] Ensure operations pages remain DB-only (no Graph calls) by extending existing checks in tests/Feature/MonitoringOperationsTest.php
- [X] T034 [US2] Run focused tests for US2 via `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls` and update specs/077-workspace-nav-monitoring-hub/quickstart.md
**Checkpoint**: Canonical operations URLs work; tenant context only affects default filter state.
---
## Phase 5: User Story 3 — Navigate and search without leaking inaccessible data (Priority: P3)
**Goal**: Enforce strict 404 vs 403 semantics without leaking admin surfaces or cross-workspace/tenant data.
**Independent Test**: Non-members get 404; members missing capability get 403, and no navigation labels hint at inaccessible features.
### Tests for User Story 3 (write first)
- [X] T035 [P] [US3] Assert non-member access to another workspaces operations is 404 in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
- [X] T036 [P] [US3] Assert member missing `workspace.manage` gets 403 on `/admin/workspaces/{record}/edit` in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
- [X] T037 [P] [US3] Assert invalid tenant context is auto-cleared when switching workspace in tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php
- [X] T038 [P] [US3] Assert reserved Monitoring placeholder pages exist (`/admin/alerts`, `/admin/audit-log`) in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
### Implementation for User Story 3
- [X] T039 [US3] Implement “auto-clear invalid tenant context” check in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T040 [US3] Confirm and implement correct Filament v5 mechanism for clearing persisted tenant state in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T041 [US3] Implement reserved Monitoring placeholder pages (Alerts, Audit Log) as Filament pages under app/Filament/Pages/Monitoring/**
- [X] T042 [US3] Ensure navigation does not expose admin-only surfaces to unauthorized users in app/Providers/Filament/AdminPanelProvider.php
- [X] T043 [US3] Verify global search does not introduce new leakage for operations/workspaces and, if needed, disable global search for resources without view/edit pages in app/Filament/**
- [X] T044 [US3] Run focused tests for US3 via `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
**Checkpoint**: 404/403 behavior matches spec; no cross-scope leaks.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Stabilize, format, and validate end-to-end.
- [X] T045 Run formatter on touched files via `./vendor/bin/sail bin pint --dirty`
- [X] T046 Run targeted full suite for touched areas via `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
- [X] T047 [P] Confirm manual quickstart steps still match UI labels and routes in specs/077-workspace-nav-monitoring-hub/quickstart.md
- [X] T048 [P] Confirm route semantics still match contracts in specs/077-workspace-nav-monitoring-hub/contracts/routes.md
- [X] T049 Ensure Filament v5 + Livewire v4 APIs are used (no v3/v4 Filament APIs) in app/Filament/**
- [X] T050 Run full suite (optional) via `./vendor/bin/sail artisan test --compact`
### Post-implementation bugfixes
- [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php
- [X] T059 Remove “Switch workspace” from sidebar navigation (workspace switching is topbar-only) in app/Providers/Filament/AdminPanelProvider.php and app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T060 Define Global Mode: make `/admin/workspaces` workspace-optional + add explicit allowlist in app/Http/Middleware/EnsureWorkspaceSelected.php
- [X] T061 Disable tenant picker when no workspace is active (Global Mode) in resources/views/filament/partials/context-bar.blade.php
- [X] T062 Remove “Manage workspaces” link from the topbar context switcher to avoid redundant entry points in resources/views/filament/partials/context-bar.blade.php
- [X] T063 Unify workspace creation authorization: ChooseWorkspace create action must use WorkspacePolicy (Gate) in app/Filament/Pages/ChooseWorkspace.php and app/Policies/WorkspacePolicy.php
---
## Phase 7: Addendum — Header Context Bar (FR-077-016)
**Goal**: Always-visible context bar for Workspace + Tenant, usable on tenantless pages without implicit switching.
### Tests (write first)
- [X] T051 [P] [FR-077-016] Assert tenant picker renders on `/admin/operations` in tests/Feature/Monitoring/HeaderContextBarTest.php
- [X] T052 [P] [FR-077-016] Assert tenant picker lists only entitled tenants in tests/Feature/Monitoring/HeaderContextBarTest.php
- [X] T053 [P] [FR-077-016] Assert deep link `/admin/operations/{run}` does not auto-switch tenant in tests/Feature/Monitoring/HeaderContextBarTest.php
### Implementation
- [X] T054 [FR-077-016] Render context bar in topbar via render hook in app/Providers/Filament/AdminPanelProvider.php
- [X] T055 [FR-077-016] Add context bar partial view in resources/views/filament/partials/context-bar.blade.php
- [X] T056 [FR-077-016] Remove implicit tenant auto-selection behavior while preserving deny-as-not-found semantics in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T057 [FR-077-016] Persist last-selected tenant per workspace session in app/Support/Workspaces/WorkspaceContext.php and controllers/pages that select tenants
**Checkpoint**: Tenant picker usable on tenantless pages; no silent tenant switching.
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 (Setup) → Phase 2 (Foundational)
- Phase 2 (Foundational) → Phase 3+ (User stories)
- Phase 3 (US1) is the MVP and should be delivered first.
- Phase 4 (US2) depends on the clarified navigation + intended-URL plumbing (Phases 12).
- Phase 5 (US3) depends on the implemented behavior from US1/US2 so it can assert non-leakage.
### User Story Dependencies
- US1 → US2: soft dependency (naming + intended redirect improves US2 flows)
- US2 → US3: recommended dependency (US3 asserts final 404/403 and filter semantics)
---
## Parallel Execution Examples
### US1 parallelizable work
- T009, T010, T011, T012 can be written in parallel (different assertions/files)
- T013, T014, T015 can be implemented in parallel (different files)
### US2 parallelizable work
- T021T024 can be written in parallel
- T028 (resource query) and T029 (table filters) can be implemented in parallel
- T031T032 can be implemented in parallel with operations filter work (different file)
### US3 parallelizable work
- T035T038 can be written in parallel
- T039T042 can be implemented in parallel (different files)
---
## Implementation Strategy
### MVP scope (recommended)
- Deliver Phase 13 (US1) only.
- Validate with `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`.
- Demo “Switch workspace” vs “Manage workspaces” clarity + correct 404/403 behavior.
### Incremental delivery
- Add US2 (canonical operations URLs + removable tenant default filter)
- Add US3 (non-leakage regression guards)
- Finish with Phase 6 polish and a full suite run

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Operations Tenantless Canonical Migration
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs) — Implementation Notes section is clearly marked non-normative; FR-078-002 mentions trait names as implementation guidance only
- [x] Focused on user value and business needs — all user stories describe user outcomes, not system internals
- [x] Written for non-technical stakeholders — principles and requirements use domain language
- [x] All mandatory sections completed — User Scenarios, Requirements, Success Criteria all present
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain — all decisions resolved (302 vs 301, KPI deferral, infolist approach)
- [x] Requirements are testable and unambiguous — each FR has specific verifiable behavior
- [x] Success criteria are measurable — SC-001 through SC-006 all have concrete pass/fail conditions
- [x] Success criteria are technology-agnostic (no implementation details) — criteria reference URLs and user outcomes, not code
- [x] All acceptance scenarios are defined — 4 user stories with given/when/then scenarios
- [x] Edge cases are identified — 5 edge cases documented including null workspace, non-numeric record, null tenant
- [x] Scope is clearly bounded — Non-Goals section explicitly excludes KPI workspace-scoping, alerts engine, capability-gating
- [x] Dependencies and assumptions identified — baseline routes, existing link helpers, constitution alignment documented
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria — FR-078-001 through FR-078-012 each specify observable behavior
- [x] User scenarios cover primary flows — canonical view, legacy redirects, contextual nav, list regression
- [x] Feature meets measurable outcomes defined in Success Criteria — SC-001 (one canonical URL), SC-003 (secure redirects), SC-005 (verification report tenantless)
- [x] No implementation details leak into specification — Implementation Notes section is non-normative; core spec is behavior-focused
## Notes
- FR-078-002 includes implementation guidance (trait names) as a non-normative hint for planners; the normative requirement is "reuse infolist schema" regardless of approach.
- Open decision on 301 vs 302 documented; 302 chosen as Phase 1 default with clear promotion path.
- KPI workspace-scoping explicitly deferred (Non-Goals + FR-078-008) — keeps migration scope focused.

View File

@ -0,0 +1,109 @@
# Route Contracts: Operations Tenantless Canonical Migration
**Feature**: 078-operations-tenantless-canonical
**Date**: 2026-02-06
---
## Canonical Routes (Retained — No Changes)
### GET /admin/operations
| Property | Value |
|----------|-------|
| **Route name** | `admin.operations.index` |
| **Handler** | `App\Filament\Pages\Monitoring\Operations` |
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware, `ensure-workspace-selected`, `ensure-filament-tenant-selected` |
| **Auth** | Requires authentication + workspace membership |
| **Scope** | Workspace-level (shows all runs in workspace) |
| **Response** | 200 HTML (Livewire page) |
### GET /admin/operations/{run}
| Property | Value |
|----------|-------|
| **Route name** | `admin.operations.view` |
| **Handler** | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` |
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware |
| **Auth** | Requires authentication + workspace membership for `$run->workspace_id` |
| **Model binding** | `{run}` resolves to `OperationRun` by ID |
| **Non-member** | 404 (deny-as-not-found) |
| **Not found** | 404 (Laravel model binding) |
| **Response** | 200 HTML (Livewire page with infolist) |
---
## Decommissioned Routes (Resource-Generated Routes Removed After Migration)
### GET /admin/t/{tenant}/operations/r/{record}
| Property | Value |
|----------|-------|
| **Route name** | `filament.admin.resources.operations.view` |
| **Status** | ❌ **REMOVED** — route no longer registered |
| **After migration** | Natural 404 |
| **Previously** | `ViewOperationRun` (Filament ViewRecord page) |
### GET /admin/t/{tenant}/operations
| Property | Value |
|----------|-------|
| **Route name** | `filament.admin.resources.operations.index` |
| **Status** | ❌ **REMOVED** — route no longer registered |
| **After migration** | Replaced by explicit convenience route `admin.operations.legacy-index` that redirects 302 → `/admin/operations` |
| **Previously** | `ListOperationRuns` (Filament ListRecords page) |
---
## Link Generation Contracts
### OperationRunLinks::view($run, $tenant)
| Property | Value |
|----------|-------|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
| **Delegates to** | `OperationRunLinks::tenantlessView($run)` |
| **Tenant parameter** | Ignored (no-op) |
| **Change** | None — already canonical |
### OperationRunLinks::tenantlessView($run)
| Property | Value |
|----------|-------|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
| **Change** | None |
### OperationRunLinks::index()
| Property | Value |
|----------|-------|
| **Returns** | `route('admin.operations.index')` |
| **Change** | None |
### OperationRunLinks::related($run, $tenant)
| Property | Value |
|----------|-------|
| **Returns** | Array of up to 11 contextual link arrays |
| **Change** | None — consumed by `TenantlessOperationRunViewer` header actions |
---
## Test Route Assertions
### Positive (must work)
| Test | Route | Expected |
|------|-------|----------|
| T-078-001 | `GET /admin/operations/{run}` | 200 (member) |
| T-078-001 | `GET /admin/operations/{run}` | 200 (run with `tenant_id = null`) |
| T-078-009 | `GET /admin/t/{tenant}/operations` | 302 redirect to `/admin/operations` |
### Negative (must 404)
| Test | Route | Expected |
|------|-------|----------|
| T-078-001 | `GET /admin/operations/{run}` | 404 (non-member) |
| T-078-002 | `GET /admin/t/{tenant}/operations/r/{record}` | 404 (any user) |
| T-078-004 | Route name `filament.admin.resources.operations.view` | Not registered |
| T-078-004 | Route name `filament.admin.resources.operations.index` | Not registered |

View File

@ -0,0 +1,94 @@
# Data Model: Operations Tenantless Canonical Migration
**Feature**: 078-operations-tenantless-canonical
**Date**: 2026-02-06
---
## Overview
This feature does **not** introduce new models or migrations. It reorganizes how existing models are rendered and routed.
## Entities (Existing — No Changes)
### OperationRun
| Field | Type | Notes |
|-------|------|-------|
| `id` | `bigint` (PK) | Auto-increment |
| `workspace_id` | `bigint` (FK) | Required. Authorization boundary. |
| `tenant_id` | `bigint` (FK, nullable) | Null for workspace-level runs (e.g., onboarding). |
| `type` | `string` | Operation type slug (e.g., `policy.sync`, `restore.execute`). |
| `status` | `enum` | `OperationRunStatus`: Queued, Running, Completed, Cancelled. |
| `outcome` | `enum` (nullable) | `OperationRunOutcome`: Succeeded, PartiallySucceeded, Failed. |
| `context` | `jsonb` | Contains `target_scope`, `verification_report`, etc. |
| `summary_counts` | `jsonb` | `{total, processed, succeeded, failed, skipped}` |
| `failure_summary` | `jsonb` (nullable) | Sanitized failure details. |
| `initiated_by` | `bigint` (FK, nullable) | User who started the run. |
| `started_at` | `timestamp` (nullable) | |
| `completed_at` | `timestamp` (nullable) | |
| `created_at` | `timestamp` | |
| `updated_at` | `timestamp` | |
**Relationships**: `belongsTo Workspace`, `belongsTo Tenant` (nullable), `belongsTo User` (initiated_by)
### WorkspaceMembership (Authorization Boundary)
The `OperationRunPolicy::view()` checks:
1. User must be a member of `$run->workspace_id`
2. Returns `Response::denyAsNotFound()` if not a member
No changes to this model or policy logic.
## Routing Changes (No Model Impact)
### Routes Removed
| Route Name | Pattern | Handler |
|------------|---------|---------|
| `filament.admin.resources.operations.index` | `GET /admin/t/{tenant}/operations` | `ListOperationRuns` (resource-generated) |
| `filament.admin.resources.operations.view` | `GET /admin/t/{tenant}/operations/r/{record}` | `ViewOperationRun` |
### Route Added
| Route Name | Pattern | Handler |
|------------|---------|---------|
| `admin.operations.legacy-index` | `GET /admin/t/{tenant}/operations` | Redirect 302 to `/admin/operations` |
### Routes Retained (Unchanged)
| Route Name | Pattern | Handler |
|------------|---------|---------|
| `admin.operations.index` | `GET /admin/operations` | `Operations.php` |
| `admin.operations.view` | `GET /admin/operations/{run}` | `TenantlessOperationRunViewer` |
### Files Deleted
| File | Reason |
|------|--------|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code (blade for OperationsDetail) |
### Files Modified
| File | Change |
|------|--------|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse infolist via schema, add related links |
| `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with `{{ $this->infolist }}` |
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Hide stats when no tenant context |
### Test Files Requiring Updates
| File | Change Required |
|------|----------------|
| `tests/Feature/Verification/VerificationAuthorizationTest.php` | Replace `OperationRunResource::getUrl('view')` with `route('admin.operations.view')` |
| `tests/Feature/OpsUx/FailureSanitizationTest.php` | Replace `OperationRunResource::getUrl('view')` with canonical route; replace `ViewOperationRun` mount |
| `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` | Update guard regex to account for headless resource |
| `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
| `tests/Feature/Verification/VerificationReportRedactionTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
| `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
| `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Remove `ListOperationRuns` test; add route-not-registered assertion |
| `tests/Feature/Monitoring/OperationsTenantScopeTest.php` | Remove `ListOperationRuns` test |

View File

@ -0,0 +1,288 @@
# Implementation Plan: Operations Tenantless Canonical Migration
**Branch**: `078-operations-tenantless-canonical` | **Date**: 2025-07-13 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/078-operations-tenantless-canonical/spec.md`
## Summary
Make Operations detail **fully canonical** at `/admin/operations/{run}` by converting `TenantlessOperationRunViewer` to reuse `OperationRunResource::infolist()` via Filament v5's unified schema system, decommissioning auto-generated tenant-scoped resource pages (they naturally 404 after route removal), cleaning up dead code, and updating all affected tests.
Key approach: Filament v5 deprecated `InteractsWithInfolists` — every `Page` already has `InteractsWithSchemas`. The existing `OperationRunResource::infolist()` is `public static` with no `$this` references and already handles `Filament::getTenant()` returning null. This means `TenantlessOperationRunViewer` can define `infolist(Schema $schema)` to delegate directly, achieving full visual parity with zero code duplication.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Filament Infolists (schema-based)
**Storage**: PostgreSQL (no new migrations — read-only model changes)
**Testing**: Pest v4 (Feature tests)
**Target Platform**: Web (Laravel Sail / Docker)
**Project Type**: Web application (monolith)
**Performance Goals**: DB-only rendering (no external calls on page load)
**Constraints**: Tenantless pages must render without `Filament::getTenant()`; no new dependencies
**Scale/Scope**: ~15 files modified/deleted, ~8 test files updated, 0 new migrations
## Constitution Check
*GATE: All pass. No violations.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | N/A | No inventory changes |
| Read/write separation | Pass | Feature is read-only (rendering changes only) |
| Graph contract path | N/A | No Graph calls |
| Deterministic capabilities | N/A | No capability changes |
| RBAC-UX planes | Pass | Workspace-level auth only; non-member = 404 (RBAC-UX-002) |
| RBAC-UX destructive | N/A | No destructive actions |
| RBAC-UX global search | Pass | Resource has `$shouldRegisterNavigation = false`, no `$recordTitleAttribute` |
| Tenant isolation | Pass | Reads workspace-scoped; no cross-tenant access |
| Run observability | N/A | No new operations; monitoring pages remain DB-only |
| Automation | N/A | No queued/scheduled work |
| Data minimization | Pass | No new data stored |
| Badge semantics | Pass | Existing `BadgeRenderer` reused via infolist — no new badge mappings |
**Post-design re-check**: Same results — no constitution violations.
## Project Structure
### Documentation (this feature)
```text
specs/078-operations-tenantless-canonical/
+-- spec.md # Feature specification
+-- plan.md # This file
+-- research.md # Phase 0: Filament v5 schema research
+-- data-model.md # Phase 1: Entity & routing changes
+-- quickstart.md # Phase 1: Verification steps
+-- contracts/
| +-- routes.md # Route contract (before/after)
+-- checklists/
| +-- requirements.md # Spec quality checklist
+-- tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (files touched)
```text
app/
+-- Filament/
| +-- Resources/
| | +-- OperationRunResource.php # getPages() returns []
| | +-- OperationRunResource/Pages/
| | +-- ViewOperationRun.php # DELETE
| | +-- ListOperationRuns.php # DELETE
| +-- Pages/
| | +-- Operations/
| | +-- TenantlessOperationRunViewer.php # Infolist reuse + related links
| +-- Widgets/
| +-- Operations/
| +-- OperationsKpiHeader.php # Hide stats when no tenant
+-- Livewire/
+-- Monitoring/
+-- OperationsDetail.php # DELETE (dead code)
resources/views/
+-- filament/pages/operations/
| +-- tenantless-operation-run-viewer.blade.php # Replace HTML with infolist render
+-- livewire/monitoring/
+-- operations-detail.blade.php # DELETE (dead code)
tests/Feature/
+-- Operations/
| +-- TenantlessOperationRunViewerTest.php # Update infolist assertions
+-- Monitoring/
| +-- OperationsCanonicalUrlsTest.php # Remove ListOperationRuns, add route-gone
| +-- OperationsTenantScopeTest.php # Remove ListOperationRuns reference
+-- Verification/
| +-- VerificationAuthorizationTest.php # Canonical route instead of getUrl
| +-- VerificationReportViewerDbOnlyTest.php # TenantlessViewer replaces ViewOperationRun
| +-- VerificationReportRedactionTest.php # TenantlessViewer replaces ViewOperationRun
| +-- VerificationReportMissingOrMalformedTest.php # TenantlessViewer replaces ViewOperationRun
+-- OpsUx/
| +-- FailureSanitizationTest.php # Canonical route + TenantlessViewer
| +-- CanonicalViewRunLinksTest.php # Update guard regex
+-- 078/ # NEW spec-specific tests
+-- CanonicalDetailRenderTest.php
+-- LegacyRoutesReturnNotFoundTest.php
+-- KpiHeaderTenantlessTest.php
+-- VerificationReportTenantlessTest.php
+-- TenantListRedirectTest.php
+-- RelatedLinksOnDetailTest.php
```
**Structure Decision**: Standard Laravel monolith. No new directories except `tests/Feature/078/` for spec tests.
## Complexity Tracking
> No constitution violations to justify.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | N/A |
---
## Implementation Phases
### Phase A — Headless Resource + Dead Code Cleanup
**Goal**: Remove auto-generated routes; delete dead code.
**Risk**: Low — removing unused pages and dead code.
**Tests first**: T-078-004 (routes not registered), T-078-002 (legacy URLs 404).
| Step | File | Change |
|------|------|--------|
| A.1 | `OperationRunResource.php` | `getPages()` returns `[]` |
| A.2 | `ViewOperationRun.php` | Delete file |
| A.3 | `ListOperationRuns.php` | Delete file |
| A.4 | `OperationsDetail.php` | Delete file (dead code) |
| A.5 | `operations-detail.blade.php` | Delete file (dead code) |
| A.6 | `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` | New: T-078-002 + T-078-004 |
| A.7 | 7 existing test files | Replace `ViewOperationRun` / `ListOperationRuns` / `getUrl('view')` references |
**Exit criteria**: All existing tests pass; legacy URLs return 404; no routes registered for resource.
### Phase B — Infolist Reuse on TenantlessOperationRunViewer
**Goal**: Replace hand-coded Blade with Filament schema-based infolist for full visual parity.
**Risk**: Medium — schema auto-discovery on standalone Page needs verification.
**Tests first**: T-078-001 (canonical detail renders), T-078-008 (verification report tenantless).
| Step | File | Change |
|------|------|--------|
| B.1 | `TenantlessOperationRunViewer.php` | Add `infolist(Schema $schema)` — delegates to `OperationRunResource::infolist($schema)` |
| B.2 | `TenantlessOperationRunViewer.php` | Add `defaultInfolist(Schema $schema)``->record($this->run)->columns(2)` |
| B.3 | `TenantlessOperationRunViewer.php` | Add `content(Schema $schema)` — returns `EmbeddedSchema::make('infolist')` |
| B.4 | `TenantlessOperationRunViewer.php` | Add `public bool $opsUxIsTabHidden = false` property (polling callback) |
| B.5 | `tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with infolist render tag |
| B.6 | `tests/Feature/078/CanonicalDetailRenderTest.php` | New: T-078-001 (+ T-078-007 guard) |
| B.7 | `tests/Feature/078/VerificationReportTenantlessTest.php` | New: T-078-008 |
**Exit criteria**: Canonical detail shows identical layout to old tenant-scoped view; verification report section renders.
### Phase C — Contextual Navigation (Related Links)
**Goal**: Replace "Admin details" button with `OperationRunLinks::related()` action group.
**Risk**: Low — mechanism already exists, just wiring.
**Tests first**: T-078-010 (related links appear), T-078-005 (no "Admin details" link).
| Step | File | Change |
|------|------|--------|
| C.1 | `TenantlessOperationRunViewer.php` | Add `getHeaderActions()` using `OperationRunLinks::related()` |
| C.2 | `TenantlessOperationRunViewer.php` | Remove "Admin details" button code (~line 61) |
| C.3 | `tests/Feature/078/RelatedLinksOnDetailTest.php` | New: T-078-010 + T-078-005 + T-078-012 |
**Exit criteria**: Related links render for different run types; "Admin details" link absent.
### Phase D — KPI Header Tenantless Handling
**Goal**: Hide KPI stats when no tenant context (not workspace-scoped — deferred).
**Risk**: Low — conditional early return.
**Tests first**: T-078-006 (KPI hidden in tenantless mode), T-078-011 (tenantless list query safety).
| Step | File | Change |
|------|------|--------|
| D.1 | `OperationsKpiHeader.php` | `getStats()`: if `Filament::getTenant()` is null then return `[]` |
| D.2 | `tests/Feature/078/KpiHeaderTenantlessTest.php` | New: T-078-006 |
| D.3 | `tests/Feature/078/OperationsListTenantlessSafetyTest.php` | New: T-078-011 |
**Exit criteria**: Operations page without tenant context renders without errors; KPI section hidden.
### Phase E — List Redirect (FR-078-012)
**Goal**: Convenience redirect for decommissioned list URL.
**Risk**: Low — single route addition.
**Tests first**: T-078-009 (302 redirect).
| Step | File | Change |
|------|------|--------|
| E.1 | `routes/web.php` | Add redirect: `/admin/t/{tenant}/operations` 302 to `/admin/operations` |
| E.2 | `tests/Feature/078/TenantListRedirectTest.php` | New: T-078-009 |
**Exit criteria**: Tenant-scoped list URL redirects; no other URLs affected.
---
## Key Design Decisions
### D-001 — Schema-based infolist (not InteractsWithInfolists)
Filament v5 unified forms/infolists/tables into the schema system. `InteractsWithInfolists` is deprecated. Every `Page` already has `InteractsWithSchemas` via `BasePage`. Define `infolist(Schema $schema)` on the page and it auto-discovers via reflection.
**See**: [research.md](research.md) R-001
### D-002 — Empty getPages() (not resource exclusion)
Returning `[]` from `getPages()` cleanly prevents all route registration while keeping the class available for `::table()` and `::infolist()` reuse. Simpler than excluding from panel discovery.
**See**: [research.md](research.md) R-003
### D-003 — Natural 404 (not redirect handlers)
After route decommission, legacy detail URLs naturally 404. No redirect handlers needed — simplest approach, zero information leakage, zero maintenance.
**See**: spec.md clarifications (FR-078-005/006 removed)
### D-004 — KPI hidden (not workspace-scoped queries)
Phase 1 hides KPI when no tenant. Full workspace-scoped KPI requires refactoring 6 queries + `ActiveRuns::existForWorkspace()` — deferred to separate spec.
**See**: [research.md](research.md) R-004, R-005
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Schema auto-discovery fails on standalone Page | Medium | Low | Research confirms it works (R-001); fallback: static builder extraction |
| Test updates miss a reference to deleted pages | Low | Medium | `grep -r` sweep for ViewOperationRun and ListOperationRuns before PR |
| Infolist polling breaks without opsUxIsTabHidden | Low | Medium | Add property explicitly; existing poll logic has null-safe fallback |
| KPI widget error when tenant is null | Low | Low | Already returns [] on null; this change makes it explicit |
---
## Test Strategy
### New Tests (spec-specific in tests/Feature/078/)
| Test ID | File | Coverage |
|---------|------|----------|
| T-078-001 | CanonicalDetailRenderTest.php | Detail renders with/without tenant_id |
| T-078-002 | LegacyRoutesReturnNotFoundTest.php | Legacy detail URLs return 404 |
| T-078-004 | LegacyRoutesReturnNotFoundTest.php | Route names not registered |
| T-078-005 | RelatedLinksOnDetailTest.php | No "Admin details" link |
| T-078-006 | KpiHeaderTenantlessTest.php | KPI hidden without tenant |
| T-078-008 | VerificationReportTenantlessTest.php | Verification report renders tenantless |
| T-078-009 | TenantListRedirectTest.php | List redirect 302 |
| T-078-010 | RelatedLinksOnDetailTest.php | Related links in header actions |
| T-078-011 | OperationsListTenantlessSafetyTest.php | List renders safely with tenant and tenantless context |
| T-078-012 | RelatedLinksOnDetailTest.php | Canonical CTA label is "View run" and legacy CTA removed |
### Updated Tests (existing)
| File | Change |
|------|--------|
| VerificationAuthorizationTest.php | `getUrl('view')` replaced with `route('admin.operations.view')` |
| FailureSanitizationTest.php | `getUrl('view')` replaced with canonical route; ViewOperationRun replaced with TenantlessOperationRunViewer |
| CanonicalViewRunLinksTest.php | Update guard regex for headless resource |
| VerificationReportViewerDbOnlyTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
| VerificationReportRedactionTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
| VerificationReportMissingOrMalformedTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
| OperationsCanonicalUrlsTest.php | Remove ListOperationRuns test, add route-gone assertion |
| OperationsTenantScopeTest.php | Remove ListOperationRuns reference |
### Focused Test Command
```bash
vendor/bin/sail artisan test --compact \
tests/Feature/078/ \
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/Verification/VerificationAuthorizationTest.php \
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
tests/Feature/Verification/VerificationReportRedactionTest.php \
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php \
tests/Feature/OpsUx/FailureSanitizationTest.php \
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
```

View File

@ -0,0 +1,80 @@
# Quickstart: Operations Tenantless Canonical Migration
**Feature**: 078-operations-tenantless-canonical
**Branch**: `078-operations-tenantless-canonical`
---
## Prerequisites
- Laravel Sail running (`vendor/bin/sail up -d`)
- Database migrated (`vendor/bin/sail artisan migrate`)
- At least one workspace with a user member
- At least one `OperationRun` record (with and without `tenant_id`)
## Verification Steps
### 1. Canonical detail renders
```bash
# Visit as authenticated workspace member
# URL: /admin/operations/{run_id}
# Expected: Full infolist renders (summary, target scope, verification report, counts, context JSON)
```
### 2. Auto-generated tenant routes are gone
```bash
vendor/bin/sail artisan route:list --name=filament.admin.resources.operations
# Expected: No routes listed (empty output)
```
### 3. Canonical list still works
```bash
# Visit: /admin/operations
# Expected: Workspace-scoped table with status tabs, filters
```
### 4. Run tests
```bash
# Run the focused test pack for this spec:
vendor/bin/sail artisan test --compact \
tests/Feature/078/ \
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/Verification/VerificationAuthorizationTest.php \
tests/Feature/OpsUx/FailureSanitizationTest.php \
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
tests/Feature/Verification/VerificationReportRedactionTest.php \
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php
# Expected: All pass
```
### 5. Pint formatting
```bash
vendor/bin/sail bin pint --dirty
```
## Key Files to Inspect
| File | What to check |
|------|---------------|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Uses schema-based infolist, has related links header |
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Returns empty stats when no tenant context |
| `app/Filament/Pages/Monitoring/Operations.php` | Unchanged — still reuses `OperationRunResource::table()` |
## What Was Deleted
| File | Why |
|------|-----|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code |

View File

@ -0,0 +1,93 @@
# Research: Operations Tenantless Canonical Migration
**Feature**: 078-operations-tenantless-canonical
**Date**: 2026-02-06
---
## R-001 — Filament v5 Infolist Reuse on Standalone Pages
**Decision**: Use the native `InteractsWithSchemas` mechanism (already on every `Page`). No need for `InteractsWithInfolists` or `HasInfolists`.
**Rationale**: In Filament v5, the schema system is unified — forms, infolists, and tables all go through `InteractsWithSchemas`. The `InteractsWithInfolists` trait is fully deprecated (every method proxies to schema equivalents). `HasInfolists` is an empty marker interface. Every `Filament\Pages\Page` already extends `BasePage` which uses `InteractsWithSchemas` and implements `HasSchemas`.
**How it works**:
1. Define `public function infolist(Schema $schema): Schema` on the Page
2. `InteractsWithSchemas` auto-discovers it via reflection
3. Render via `{{ $this->infolist }}` (magic property) or `EmbeddedSchema::make('infolist')`
**Alternatives considered**:
- Adding `InteractsWithInfolists` trait → Rejected: deprecated shim, adds no functionality
- Extracting a static builder from `OperationRunResource` → Not needed: `infolist()` is already `static`
**Source files verified**:
- `vendor/filament/infolists/src/Concerns/InteractsWithInfolists.php` — all methods `@deprecated`
- `vendor/filament/infolists/src/Contracts/HasInfolists.php` — empty interface
- `vendor/filament/support/src/Pages/BasePage.php``implements HasSchemas`, uses `InteractsWithSchemas`
- `vendor/filament/support/src/Concerns/InteractsWithSchemas.php` — auto-discovers methods by name
---
## R-002 — OperationRunResource::infolist() Compatibility
**Decision**: Call `OperationRunResource::infolist($schema)` directly from the standalone Page.
**Rationale**: The method is `public static`, uses no `$this` references, and already handles tenantless context gracefully (`Filament::getTenant()` returns null → falls back to `OperationRunLinks::tenantlessView()`).
**Requirements for the standalone Page**:
1. `public function infolist(Schema $schema): Schema` → delegates to `OperationRunResource::infolist($schema)`
2. `public function defaultInfolist(Schema $schema): Schema` → sets `->record($this->run)->columns(2)`
3. `public bool $opsUxIsTabHidden = false` property → needed for polling callback in infolist
4. Override `content()` or render via Blade `{{ $this->infolist }}`
**Alternatives considered**:
- Extracting infolist into a shared trait → Overengineered for one consumer
- Keeping hand-coded Blade → Defeats the single-source goal
---
## R-003 — Headless Resource Pattern (Empty getPages)
**Decision**: Return `[]` from `OperationRunResource::getPages()` to eliminate all auto-generated routes.
**Rationale**: `Resource::routes()` iterates `getPages()` in a `foreach` — empty array means zero route registrations. The resource class is retained as a static utility providing `::table()` and `::infolist()` schema builders.
**Impact**: The following route names will no longer exist:
- `filament.admin.resources.operations.index`
- `filament.admin.resources.operations.view`
**Files that reference these routes** (need updating):
- `tests/Feature/Verification/VerificationAuthorizationTest.php` (L38, L74) — `OperationRunResource::getUrl('view', ...)`
- `tests/Feature/OpsUx/FailureSanitizationTest.php` (L58) — `OperationRunResource::getUrl('view', ...)`
- `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` (L25) — guard test scanning for stale references (update regex)
- `tests/Feature/Verification/VerificationDbOnlyTest.php``ViewOperationRun` Livewire test
- `tests/Feature/OpsUx/FailureSanitizationTest.php``ViewOperationRun` Livewire test
- `tests/Feature/Verification/VerificationReportRenderingTest.php``ViewOperationRun` Livewire test
- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` (L121) — `ListOperationRuns` Livewire test
- `tests/Feature/Monitoring/OperationsTenantScopeTest.php` (L119) — `ListOperationRuns` Livewire test
**Alternatives considered**:
- Keeping a dummy page that redirects → Adds complexity, spec says natural 404
- Excluding resource from discovery → More fragile than empty `getPages()`
---
## R-004 — KPI Header Tenantless Behavior
**Decision**: Hide `OperationsKpiHeader` when no tenant context is available (Phase 1).
**Rationale**: The widget runs 6 queries all scoped by `tenant_id`. Without tenant context, all queries return 0. Showing zeros is misleading. Workspace-scoped queries are a larger refactor deferred to a separate spec.
**Implementation**: Check `Filament::getTenant()` in the widget's `getStats()` — return empty array if null. Or conditionally register the widget on the page based on tenant presence.
**Source**: `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — 131 lines, 4 stat cards, `$isLazy = false`
---
## R-005 — Legacy List Redirect (FR-078-012)
**Decision**: Add a 302 redirect from `/admin/t/{tenant}/operations` to `/admin/operations`.
**Rationale**: Unlike detail URLs which naturally 404 after page decommission, the list URL pattern may still be reached through Filament's navigation system during the transition period. A simple redirect avoids confusion.
**Note**: This is the only redirect in the spec. All detail-level legacy URLs naturally 404.

View File

@ -0,0 +1,343 @@
# Feature Specification: Operations Tenantless Canonical Migration
**Feature Branch**: `078-operations-tenantless-canonical`
**Created**: 2026-02-06
**Status**: Draft
**Stack**: Filament v5 + Livewire v4 (native only)
**Input**: Eliminate dual "run detail" surfaces (tenant-scoped Filament view vs tenantless canonical viewer) and make Operations truly canonical, supportable, and secure.
---
## 0. Executive Summary
Operations is already **canonical at the index and tenantless detail** (`/admin/operations`, `/admin/operations/{run}`), but **auto-generated tenant-scoped Filament resource routes** still exist as side-effects of panel discovery:
- Tenant-scoped detail: `/admin/t/{tenant}/operations/r/{record}`
- Tenant-scoped list: `/admin/t/{tenant}/operations`
This spec makes the tenantless detail page **the only run detail view**, removes the auto-generated tenant-scoped pages (legacy detail URLs naturally 404), and ensures **deny-as-not-found** security—without introducing non-native UI frameworks.
---
## Clarifications
### Session 2026-02-06
- Q: Should a tenant-scoped legacy URL (`/admin/t/{tenant}/operations/r/{record}`) for a run with `tenant_id = null` be allowed (redirect) or blocked (404)? → A: ~~Allow redirect~~ Superseded — FR-078-005/006 removed; legacy detail URLs naturally 404 after route decommission.
---
## 1. Goals
1. **Single canonical run detail view**`/admin/operations/{run}`
2. **Remove auto-generated tenant-scoped pages** — decommission both `/admin/t/{tenant}/operations/r/{record}` and `/admin/t/{tenant}/operations`
3. **Clean decommission** — legacy tenant-scoped detail URLs naturally 404 after route removal (no redirect handlers needed)
4. **No duplication of UI logic** — reuse `OperationRunResource::infolist()` in the tenantless viewer
5. **Enterprise security semantics** — non-member → 404, no existence leakage
---
## 2. Non-Goals
- Building a new operations dashboard
- Introducing an alerts engine
- Changing operation execution semantics
- Implementing fine-grained `operations.view` capabilities (access remains workspace-membership)
- Workspace-scoped KPI header (tracked separately; header hidden in tenantless mode for Phase 1)
---
## 3. Current State (Baseline)
**Registered routes (today):**
| Route | Pattern | Name | Handler | Scope |
|-------|---------|------|---------|-------|
| Canonical list | `GET /admin/operations` | `admin.operations.index` | `Operations.php` (custom page) | Workspace (session) |
| Canonical detail | `GET /admin/operations/{run}` | `admin.operations.view` | `TenantlessOperationRunViewer.php` | Workspace membership |
| Auto-generated list | `GET /admin/t/{tenant}/operations` | `filament.admin.resources.operations.index` | `ListOperationRuns.php` | Tenant-scoped |
| Auto-generated view | `GET /admin/t/{tenant}/operations/r/{record}` | `filament.admin.resources.operations.view` | `ViewOperationRun.php` | Tenant-scoped |
**Key constraints:**
- Tenant-scoped routes exist because `OperationRunResource` is auto-discovered in a tenant panel (`AdminPanelProvider::discoverResources`).
- All production "View run" links already point to canonical tenantless detail via `OperationRunLinks::view()``OperationRunLinks::tenantlessView()`.
- The resource already declares `$isScopedToTenant = false` and `$shouldRegisterNavigation = false`.
**Link helper state:**
- `OperationRunLinks::view($run, $tenant)` delegates to `tenantlessView($run)` — the `$tenant` parameter is a no-op.
- `OperationRunLinks::related($run, $tenant)` returns up to 11 contextual links (Policies, Inventory, Drift, Backup Sets, Provider Connections, etc.).
- `OperationRunUrl::view()` and `OperationRunUrl::index()` are thin wrappers around `OperationRunLinks`.
---
## 4. Enterprise Principles (Normative)
### P-078-001 — Canonical deep links
There MUST be exactly one canonical URL to open a run: `/admin/operations/{run}`.
### P-078-002 — No tenant context dependency
Canonical run detail MUST render without tenant context and MUST NOT require `/admin/t/{tenant}`.
### P-078-003 — Deny-as-not-found
Any user not entitled to view a run MUST receive **404**, not 403. (Constitution: RBAC-UX-002)
### P-078-004 — No leakage via legacy URLs
Decommissioned tenant-scoped routes MUST NOT exist after migration. Removed routes naturally return 404 — no redirect handlers, no existence leakage.
### P-078-005 — Filament-native
Use Filament pages/resources/infolists/actions and Livewire v4 only. No custom SPA routing.
---
## User Scenarios & Testing
### User Story 1 — View operation run via canonical URL (Priority: P1)
A workspace member clicks a "View run" link (from notification, widget, or operations list) and sees the full run detail at `/admin/operations/{run}` — regardless of whether the run has a tenant or not.
**Why this priority**: This is the core feature — the single canonical detail surface that replaces the dual-surface.
**Independent Test**: Create runs with and without `tenant_id`, navigate to `/admin/operations/{run}`, assert all sections render (summary, target scope, verification report, context JSON).
**Acceptance Scenarios**:
1. **Given** a run with `tenant_id` set and user is workspace member, **When** user visits `/admin/operations/{run}`, **Then** full detail renders including target scope, verification report (if present), summary counts, and context JSON.
2. **Given** a run with `tenant_id = null` (e.g., onboarding run), **When** user visits `/admin/operations/{run}`, **Then** detail renders without crash; target scope shows "No target scope details recorded."
3. **Given** a run with a verification report in context, **When** user visits canonical detail, **Then** verification report section renders correctly with badge rendering and acknowledgements — even though `Filament::getTenant()` returns null.
4. **Given** user is NOT a workspace member, **When** user visits `/admin/operations/{run}`, **Then** 404 (deny-as-not-found).
---
### User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
A user has a bookmarked or old notification link pointing to `/admin/t/{tenant}/operations/r/{record}`. After route decommission, the system returns 404 — no redirect, no existence leakage.
**Why this priority**: Ensures decommissioned routes don't silently serve stale pages or leak information.
**Independent Test**: Hit legacy detail URLs; assert 404 for all users regardless of membership.
**Acceptance Scenarios**:
1. **Given** any user (member or non-member), **When** user visits `/admin/t/{tenant}/operations/r/{record}`, **Then** 404.
2. **Given** any user, **When** user visits `/admin/operations/r/{record}`, **Then** 404 (the `/r/` slug variant also does not exist).
---
### User Story 3 — Contextual navigation from run detail (Priority: P2)
On the canonical detail page, a workspace member sees contextual "Open" actions (e.g., "Open tenant", "Policies", "Backup Set", "Provider Connection") based on the run's context — using the existing `OperationRunLinks::related()` mechanism.
**Why this priority**: Replaces the "Admin details" back-link with richer, already-implemented navigation.
**Independent Test**: Create runs of different types, verify that the related links appear in the header actions group.
**Acceptance Scenarios**:
1. **Given** a run of type `policy.sync` with `policy_id` in context, **When** viewing canonical detail, **Then** header shows "Open" group with links to Operations index, Policy, and Policies list.
2. **Given** a run with `tenant_id` and user is tenant member, **When** viewing canonical detail, **Then** related links include tenant-scoped resources (via `OperationRunLinks::related()`).
3. **Given** a run with `tenant_id = null`, **When** viewing canonical detail, **Then** no tenant-specific related links appear; only generic links (Operations index).
---
### User Story 4 — Operations list remains workspace-scoped (Priority: P3)
The `/admin/operations` page continues to show all runs for the current workspace, with an optional tenant default filter when tenant context is active.
**Why this priority**: Existing behavior; this story ensures no regression during migration.
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping and default filter behavior.
**Acceptance Scenarios**:
1. **Given** workspace context set but no tenant context, **When** visiting `/admin/operations`, **Then** all workspace runs shown.
2. **Given** tenant context active, **When** visiting `/admin/operations`, **Then** tenant filter defaults to active tenant but can be cleared to show all.
---
### Edge Cases
- Run with `workspace_id = 0` or null → 404 (existing behavior in `OperationRunPolicy::view`)
- Run exists but requesting user has no workspace membership → 404 (deny-as-not-found)
- Verification report section with `Filament::getTenant()` returning null → falls back to `OperationRunLinks::tenantlessView()` for previous-run URLs (already handled in infolist)
- Legacy tenant-scoped URLs (`/admin/t/{tenant}/operations/r/{record}`) → 404 (routes no longer registered after decommission)
---
## Requirements
**Constitution alignment (RBAC-UX):**
- Authorization plane: Workspace-level (not tenant-level). Operations detail requires workspace membership only.
- 404 vs 403: Non-member → 404 (RBAC-UX-002). No capability-gating for view-only operations access in this spec.
- Server-side enforcement: `OperationRunPolicy::view()` + `WorkspaceMembership` check in `TenantlessOperationRunViewer::mount()`.
- Legacy tenant-scoped routes are decommissioned (naturally 404); no redirect handlers needed (P-078-004).
- Global search: `OperationRunResource` has `$shouldRegisterNavigation = false` and no `$recordTitleAttribute` — not globally searchable.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no auth handshakes involved.
**Constitution alignment (BADGE-001):** No badge changes. Existing badge mappings (`OperationRunStatus`, `OperationRunOutcome`) are reused via shared `BadgeRenderer` in the reused infolist.
### Functional Requirements
#### 5.1 Canonical Run Detail — Feature-Complete Tenantless Page
- **FR-078-001**: `GET /admin/operations/{run}` MUST display: run summary (status/outcome/timestamps/initiator), target scope, verification report (DB-only), summary counts, failure summary, and context JSON (redacted).
- **FR-078-002**: Tenantless detail page MUST reuse `OperationRunResource::infolist()` via Filament v5's native schema system. `TenantlessOperationRunViewer` MUST define `public function infolist(Schema $schema): Schema` delegating to `OperationRunResource::infolist($schema)`, provide `defaultInfolist(Schema $schema)` to bind `->record($this->run)->columns(2)`, and render the schema via `{{ $this->infolist }}` (or `EmbeddedSchema::make('infolist')` in `content()`).
- **FR-078-003**: Tenantless detail MUST reuse `OperationRunLinks::related($run, $tenant)` for contextual header actions (replacing the removed "Admin details" button). If `$run->tenant` is null, only generic links (Operations index) appear.
#### 5.2 Decommission Auto-Generated Tenant-Scoped Pages
- **FR-078-004**: Remove the `'view'` page entry from `OperationRunResource::getPages()` and delete `ViewOperationRun.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations/r/{record}` route.
- **FR-078-011**: Remove the `'index'` page entry from `OperationRunResource::getPages()` and delete `ListOperationRuns.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations` route. The resource retains only its `table()` and `infolist()` schema builders for reuse by the custom pages.
#### 5.3 Legacy URL Handling
Legacy detail URLs (`/admin/t/{tenant}/operations/r/{record}` and `/admin/operations/r/{record}`) naturally return 404 after route decommission — no redirect handlers are needed. ~~FR-078-005 and FR-078-006 removed.~~
- **FR-078-012**: Add a convenience redirect for `GET /admin/t/{tenant}/operations`:
1. Redirect **302** to `/admin/operations`.
Note: No auth check needed since `/admin/operations` already enforces workspace membership.
#### 5.4 KPI Header Handling (Phase 1 Simplification)
- **FR-078-008**: `OperationsKpiHeader` widget MUST continue to render when tenant context is available. When no tenant context exists (tenantless pages), the KPI header SHOULD be hidden (not rendered) rather than showing zeros. Full workspace-scoped KPI support is deferred to a follow-up spec.
- **FR-078-009**: Polling/active-run queries on the canonical list page (`Operations.php`) MUST handle `tenant_id = null` runs and tenantless rendering safely. This MUST be verified by an explicit regression test covering list rendering with and without tenant context.
#### 5.5 Dead Code Cleanup
- **FR-078-010**: Delete `app/Livewire/Monitoring/OperationsDetail.php` and its Blade view `resources/views/livewire/monitoring/operations-detail.blade.php`. This component is unreferenced dead code that enforces an obsolete tenant-only abort check.
### Key Entities
- **OperationRun**: The central model. Has `workspace_id` (required for authorization), `tenant_id` (nullable — onboarding/provider-check runs may lack it), `context` (JSONB with target_scope, verification_report, etc.), `summary_counts`, `failure_summary`.
- **WorkspaceMembership**: The authorization boundary. View access requires membership in the run's workspace.
- **OperationRunLinks**: Centralized link helper. `view()``tenantlessView()` (already canonical). `related()` provides contextual navigation links.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Exactly one canonical run detail URL exists: `/admin/operations/{run}`. The route `filament.admin.resources.operations.view` is no longer registered.
- **SC-002**: All "View run" links across notifications, widgets, tables, and jobs resolve to `/admin/operations/{run}` (no tenant-scoped detail links remain).
- **SC-003**: Legacy tenant-scoped detail URLs return 404 after route decommission (no redirect handlers, no information leakage).
- **SC-004**: Runs with `tenant_id = null` render on canonical detail without errors.
- **SC-005**: Verification report section renders correctly on canonical detail when `Filament::getTenant()` returns null.
- **SC-006**: All existing Pest tests pass after migration (no regressions).
---
## Security & RBAC
### SR-078-001 — View permissions
- Viewing operations list and run detail requires workspace membership (baseline).
- Non-member → 404 (deny-as-not-found, per RBAC-UX-002).
- No fine-grained capabilities for operations view in this spec.
### SR-078-002 — No sensitive leakage in context JSON
- Context JSON shown in UI uses allowlist-based redaction (existing behavior).
- The verification report section already sanitizes sensitive data.
---
## UX Requirements
### UX-078-001 — Consistent CTA label
All run CTAs MUST use the canonical label per ux-contracts.md:
- **"View run"** (exact casing)
### UX-078-002 — Context navigation via related links
Canonical detail header MUST show an "Open" action group populated by `OperationRunLinks::related()`. This provides all relevant contextual links (Operations index, Provider Connection, Policies, Backup Sets, Restore Runs, Drift, Inventory, etc.) based on run type and context.
### UX-078-003 — Empty/missing target scope
If target scope is missing: show the existing non-blocking text "No target scope details were recorded for this run." Do not crash; degrade gracefully.
---
## Tests (Mandatory)
### T-078-001 — Canonical run detail renders without tenant context
- Create run with `tenant_id = null` and with `tenant_id` set
- Assert `/admin/operations/{run}` renders summary sections without crash
### T-078-002 — Legacy tenant-scoped detail URLs return 404
- Any user visiting `/admin/t/{tenant}/operations/r/{record}` → 404 (route not registered)
- Any user visiting `/admin/operations/r/{record}` → 404 (route not registered)
### T-078-004 — No auto-generated tenant-scoped routes exist
- Assert route names `filament.admin.resources.operations.view` and `filament.admin.resources.operations.index` are not registered (or return 404)
### T-078-005 — No "Admin details" link on canonical detail
- Assert canonical detail page does not render `/admin/t/.../operations/r/...` links
### T-078-006 — KPI header hidden in tenantless mode
- On `/admin/operations` without tenant context, KPI header does not render (or renders gracefully)
### T-078-007 — DB-only rendering
- Rendering canonical detail does not dispatch jobs or perform HTTP calls (existing guard tests remain valid)
### T-078-008 — Verification report renders on tenantless detail
- Create run with verification report in context and `tenant_id` set
- Visit `/admin/operations/{run}` without `Filament::getTenant()` set
- Assert verification report section renders (badge, acknowledgements, change indicator with tenantless previous-run URL)
### T-078-009 — Tenant-scoped list redirect
- `/admin/t/{tenant}/operations` redirects (302) to `/admin/operations`
### T-078-010 — Related links appear on canonical detail
- Create run of type `restore.execute` with `restore_run_id` in context
- Assert header actions include "Restore Run" link
### T-078-011 — Operations list tenantless safety
- Visit `/admin/operations` with and without tenant context
- Assert list renders successfully and includes `tenant_id = null` runs without polling/query errors
### T-078-012 — Canonical CTA label consistency
- Assert run entry points use the exact label **"View run"**
- Assert canonical detail no longer shows the legacy "Admin details" CTA
---
## Acceptance Criteria
- ✅ Exactly one canonical run detail URL: `/admin/operations/{run}`
- ✅ Auto-generated tenant-scoped routes (list + view) are removed; legacy detail URLs return 404
- ✅ No "Admin details" / tenant-scoped back-links from canonical pages
- ✅ Operations list works in workspace mode; tenant context only adds default filter
- ✅ Runs with `tenant_id = null` display safely
- ✅ Verification report renders correctly without tenant context
- ✅ Related contextual links (from `OperationRunLinks::related()`) replace the removed "Admin details" button
- ✅ Dead code (`OperationsDetail.php` + Blade view) removed
- ✅ Tests cover 404 semantics, infolist rendering, and canonical link rules
- ✅ Implementation is Filament v5 + Livewire v4 native only
---
## Implementation Notes (Non-Normative)
### Infolist Reuse Strategy
Use Filament v5's native schema flow on the standalone page:
- Define `infolist(Schema $schema)` and delegate to `OperationRunResource::infolist($schema)`
- Define `defaultInfolist(Schema $schema)` with `->record($this->run)->columns(2)`
- Render with `{{ $this->infolist }}` (and use `EmbeddedSchema::make('infolist')` from `content()` when needed)
### Resource After Decommission
After removing both page entries from `getPages()`, `OperationRunResource` becomes a "headless" resource — it provides `table()` and `infolist()` schema builders reused by:
- `Operations.php` (custom list page, via `OperationRunResource::table($table)`)
- `TenantlessOperationRunViewer` (canonical detail, via infolist delegation)
The resource class itself is retained; only its auto-generated routes are eliminated.
### KPI Header Deferral
`OperationsKpiHeader` currently queries 6 times by `tenant_id` and uses `ActiveRuns::existForTenant()`. Full workspace-scoping requires adding `existForWorkspace()` to `ActiveRuns` and refactoring all 6 queries. This is deferred to a separate spec to keep this migration focused. Phase 1 simply hides the KPI header when tenant context is absent.
---
## Open Decisions
- **"Copy JSON" capability-gating**: Recommended for enterprise environments but not in scope for this spec.

View File

@ -0,0 +1,262 @@
# Tasks: Operations Tenantless Canonical Migration
**Input**: Design documents from `/specs/078-operations-tenantless-canonical/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Tests are REQUIRED (Pest). This feature changes runtime routing and rendering behavior.
**Operations**: No new operations introduced. Existing `OperationRun` model is read-only in this feature.
**RBAC**: Authorization unchanged — workspace membership enforced via `OperationRunPolicy::view()`. Non-member gets 404 (deny-as-not-found). No new capabilities, no destructive actions.
**Badges**: No badge changes. Existing `BadgeRenderer` reused via shared infolist schema.
**Organization**: Tasks are grouped by user story. US1 (P1) is the MVP. US2/US3 (P2) can proceed in parallel after foundational phase. US4 (P3) is independent.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4)
- Exact file paths included in descriptions
## Path Conventions
- Standard Laravel monolith: `app/`, `resources/`, `routes/`, `tests/`, `config/`
- New spec tests: `tests/Feature/078/`
---
## Phase 1: Setup
**Purpose**: Create test directory and verify branch readiness
- [X] T001 Create spec test directory `tests/Feature/078/`
- [X] T002 Verify branch is clean and on `078-operations-tenantless-canonical`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Make `OperationRunResource` headless, delete decommissioned pages, delete dead code, and fix all existing tests that reference deleted classes/routes. This phase MUST complete before any user story work.
**Why blocking**: US1 needs ViewOperationRun deleted so infolist lives on standalone page. US2 needs routes removed. US3 needs the viewer to be the sole detail surface. All existing tests must pass after this phase.
- [X] T003 Change `getPages()` to return `[]` in `app/Filament/Resources/OperationRunResource.php`
- [X] T004 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
- [X] T005 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php`
- [X] T006 [P] Delete `app/Livewire/Monitoring/OperationsDetail.php` (dead code)
- [X] T007 [P] Delete `resources/views/livewire/monitoring/operations-detail.blade.php` (dead code)
- [X] T008 Update `tests/Feature/Verification/VerificationAuthorizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with `route('admin.operations.view', ...)` and replace `ViewOperationRun` Livewire mount with `TenantlessOperationRunViewer`
- [X] T009 [P] Update `tests/Feature/OpsUx/FailureSanitizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with canonical route; replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
- [X] T010 [P] Update `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` — update guard regex to account for headless resource (no `getUrl` calls exist)
- [X] T011 [P] Update `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` — replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
- [X] T012 [P] Update `tests/Feature/Verification/VerificationReportRedactionTest.php` + `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` — replace `ViewOperationRun` mounts with `TenantlessOperationRunViewer`
- [X] T013 [P] Update `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` — remove `ListOperationRuns` test block; add route-not-registered assertion for `filament.admin.resources.operations.index`
- [X] T014 [P] Update `tests/Feature/Monitoring/OperationsTenantScopeTest.php` — remove `ListOperationRuns` reference
- [X] T015 Run `grep -r "ViewOperationRun\|ListOperationRuns" app/ tests/ resources/` to verify no stale references remain
- [X] T016 Run existing test suite for affected files: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/OpsUx/ tests/Feature/Monitoring/ tests/Feature/Operations/`
**Checkpoint**: All existing tests pass. Resource is headless. Dead code removed. No stale references.
---
## Phase 3: User Story 1 — View operation run via canonical URL (Priority: P1) MVP
**Goal**: The canonical detail page at `/admin/operations/{run}` renders with full Filament infolist (summary, target scope, verification report, counts, context JSON) — replacing the current hand-coded Blade with `OperationRunResource::infolist()` reuse via the unified schema system.
**Independent Test**: Create runs with and without `tenant_id`, visit `/admin/operations/{run}`, assert all infolist sections render.
### Tests for User Story 1
- [X] T017 [P] [US1] Write test T-078-001 (canonical detail renders with tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id, visit canonical URL as workspace member, assert 200 + infolist sections visible (status badge, outcome, timestamps, target scope, summary counts)
- [X] T018 [P] [US1] Write test T-078-001 (canonical detail renders without tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id=null, visit canonical URL, assert 200 + graceful rendering ("No target scope details")
- [X] T019 [P] [US1] Write test T-078-001 (non-member gets 404) in `tests/Feature/078/CanonicalDetailRenderTest.php` — visit as non-member, assert 404
- [X] T020 [P] [US1] Write test T-078-008 (verification report renders tenantless) in `tests/Feature/078/VerificationReportTenantlessTest.php` — create run with verification_report in context, visit canonical URL, assert verification section renders with badges and acknowledgements
- [X] T021 [P] [US1] Write test T-078-007 (DB-only rendering) in `tests/Feature/078/CanonicalDetailRenderTest.php` — assert canonical detail rendering does not dispatch jobs or HTTP calls
### Implementation for User Story 1
- [X] T022 [US1] Add `public function infolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — delegates to `OperationRunResource::infolist($schema)`
- [X] T023 [US1] Add `public function defaultInfolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — sets `->record($this->run)->columns(2)`
- [X] T024 [US1] Add `public bool $opsUxIsTabHidden = false` property to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (required for polling callback in infolist)
- [X] T025 [US1] Add `public function content(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` returning `EmbeddedSchema::make('infolist')`
- [X] T026 [US1] Replace hand-coded HTML in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` with infolist render (`{{ $this->infolist }}`)
- [X] T027 [US1] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/CanonicalDetailRenderTest.php tests/Feature/078/VerificationReportTenantlessTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
**Checkpoint**: Canonical detail at `/admin/operations/{run}` renders full Filament infolist. Runs with and without tenant_id render correctly. MVP is functional.
---
## Phase 4: User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
**Goal**: Verify that decommissioned tenant-scoped routes naturally return 404. No redirect handlers, no existence leakage.
**Independent Test**: Hit legacy detail URLs; assert 404 for all users.
**Note**: The actual route removal was done in Phase 2 (Foundational). This phase adds the spec-required tests that validate the behavior.
### Tests for User Story 2
- [X] T028 [P] [US2] Write test T-078-002 (legacy detail URL returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/t/{tenant}/operations/r/{record}` as any user, assert 404
- [X] T029 [P] [US2] Write test T-078-002 (slug variant returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/operations/r/{record}`, assert 404
- [X] T030 [P] [US2] Write test T-078-004 (auto-generated route names not registered) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — assert `Route::has('filament.admin.resources.operations.view')` is false and `Route::has('filament.admin.resources.operations.index')` is false
### Implementation for User Story 2
> No additional implementation needed — routes were removed in Phase 2.
- [X] T031 [US2] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/LegacyRoutesReturnNotFoundTest.php`
**Checkpoint**: All legacy URLs return 404. Route names are not registered. No existence leakage.
---
## Phase 5: User Story 3 — Contextual navigation from run detail (Priority: P2)
**Goal**: Replace the "Admin details" button on canonical detail with `OperationRunLinks::related()` action group in header actions — providing richer contextual navigation (Operations index, Policy, Backup Set, Restore Run, etc.).
**Independent Test**: Create runs of different types, verify related links appear in header actions and "Admin details" link is absent.
### Tests for User Story 3
- [X] T032 [P] [US3] Write test T-078-010 (related links appear) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run of type `restore.execute` with `restore_run_id` in context, visit canonical detail, assert header actions include "Restore Run" link
- [X] T033 [P] [US3] Write test T-078-010 (generic links for tenantless run) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run with tenant_id=null, assert only generic links (Operations index) appear
- [X] T034 [P] [US3] Write tests T-078-005 + T-078-012 in `tests/Feature/078/RelatedLinksOnDetailTest.php` — assert canonical detail does not render `/admin/t/.../operations/r/...` links and uses exact CTA label "View run" (legacy "Admin details" absent)
### Implementation for User Story 3
- [X] T035 [US3] Add `getHeaderActions()` method to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — return actions from `OperationRunLinks::related($this->run, $this->run->tenant)`
- [X] T036 [US3] Remove "Admin details" button code (~line 61) from `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T037 [US3] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/RelatedLinksOnDetailTest.php`
**Checkpoint**: Header shows contextual "Open" action group. "Admin details" link is gone. Related links vary by run type.
---
## Phase 6: User Story 4 — Operations list remains workspace-scoped (Priority: P3)
**Goal**: Ensure no regression on `/admin/operations` list. KPI header hidden in tenantless mode. 302 redirect for decommissioned list URL.
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping, KPI behavior, and redirect.
### Tests for User Story 4
- [X] T038 [P] [US4] Write test T-078-006 (KPI header hidden without tenant) in `tests/Feature/078/KpiHeaderTenantlessTest.php` — visit `/admin/operations` without tenant context, assert KPI stats are not rendered (empty array from `getStats()`)
- [X] T039 [P] [US4] Write test T-078-009 (tenant-scoped list redirect) in `tests/Feature/078/TenantListRedirectTest.php` — visit `/admin/t/{tenant}/operations`, assert 302 redirect to `/admin/operations`
- [X] T048 [P] [US4] Write test T-078-011 (tenantless list query safety) in `tests/Feature/078/OperationsListTenantlessSafetyTest.php` — visit `/admin/operations` with and without tenant context, assert runs including `tenant_id = null` render without errors
### Implementation for User Story 4
- [X] T040 [US4] Add tenant-null guard in `getStats()` of `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — if `Filament::getTenant()` is null, return `[]`
- [X] T041 [US4] Add 302 redirect route in `routes/web.php``/admin/t/{tenant}/operations` redirects to `/admin/operations` (FR-078-012)
- [X] T042 [US4] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/KpiHeaderTenantlessTest.php tests/Feature/078/TenantListRedirectTest.php tests/Feature/078/OperationsListTenantlessSafetyTest.php`
**Checkpoint**: Operations list works in workspace mode. KPI hidden without tenant. List redirect works.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final validation, formatting, and stale reference sweep
- [X] T043 Run `grep -r "ViewOperationRun\|ListOperationRuns\|OperationsDetail" app/ tests/ resources/` — verify zero stale references across entire codebase
- [X] T044 Run `vendor/bin/sail bin pint --dirty` — fix any formatting issues
- [X] T049 Remove obsolete temporary layout `resources/views/filament/layouts/topbar-only.blade.php`
- [X] T045 Run focused test pack: `vendor/bin/sail artisan test --compact tests/Feature/078/ tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Verification/VerificationAuthorizationTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Feature/Verification/VerificationReportRedactionTest.php tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php tests/Feature/OpsUx/FailureSanitizationTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
- [X] T046 Run quickstart.md validation: `vendor/bin/sail artisan route:list --name=filament.admin.resources.operations` — assert empty output
- [X] T047 Ask user if they want to run the full test suite: `vendor/bin/sail artisan test --compact`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories
- **US1 (Phase 3)**: Depends on Phase 2 completion (resource must be headless first)
- **US2 (Phase 4)**: Depends on Phase 2 completion (routes must be removed)
- **US3 (Phase 5)**: Depends on Phase 3 completion (viewer must have infolist before adding header actions)
- **US4 (Phase 6)**: Depends on Phase 2 completion only (independent of US1/US2/US3)
- **Polish (Phase 7)**: Depends on all desired user stories being complete
### User Story Dependencies
- **US1 (P1)**: After Phase 2 — no other story dependencies. **This is the MVP.**
- **US2 (P2)**: After Phase 2 — independent of US1 (tests only, implementation in Phase 2)
- **US3 (P2)**: After US1 (Phase 3) — needs infolist on viewer before adding header actions
- **US4 (P3)**: After Phase 2 — independent of US1/US2/US3
### Within Each User Story
- Tests written FIRST, verified to FAIL before implementation
- Implementation follows test guidance
- Story checkpoint validates independently
### Parallel Opportunities
After Phase 2 completes:
- **US1 and US2 and US4** can proceed in parallel (different files, no dependencies)
- US3 must wait for US1 (same file: TenantlessOperationRunViewer.php)
Within Phase 2:
- T004, T005, T006, T007 (file deletions) can all run in parallel
- T008-T014 (test file updates) can all run in parallel
Within Phase 3 (US1):
- T017-T021 (test writing) can all run in parallel
- T022-T026 (implementation) are sequential (same file)
---
## Parallel Example: After Phase 2
```
# US1 (developer/agent A):
T017-T021: Write US1 tests (parallel)
T022-T026: Implement infolist reuse (sequential, same file)
T027: Run US1 tests
# US2 (developer/agent B — can run simultaneously):
T028-T030: Write US2 tests (parallel)
T031: Run US2 tests
# US4 (developer/agent C — can run simultaneously):
T038-T039: Write US4 tests (parallel)
T040-T041: Implement KPI guard + redirect
T042: Run US4 tests
# US3 (must wait for US1):
T032-T034: Write US3 tests (parallel)
T035-T036: Implement related links
T037: Run US3 tests
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (headless resource + dead code + test fixes)
3. Complete Phase 3: User Story 1 (infolist reuse on canonical detail)
4. **STOP and VALIDATE**: Canonical detail renders full infolist for all run types
5. This is deployable as MVP — core value delivered
### Incremental Delivery
1. Setup + Foundational -> Foundation ready (all existing tests pass)
2. Add US1 -> Canonical detail has full infolist (MVP!)
3. Add US2 -> Legacy URLs confirmed 404 (validation tests)
4. Add US3 -> Related links replace "Admin details" button
5. Add US4 -> KPI hidden + list redirect
6. Polish -> Full sweep, formatting, final validation
---
## Notes
- All tests use Pest (not PHPUnit class syntax)
- Use existing `OperationRun` factory for test data setup
- `TenantlessOperationRunViewer` is the sole detail surface after migration
- `OperationRunResource` retained as headless utility (provides `::table()` and `::infolist()`)
- No new migrations, no new models, no new dependencies
- Total: 47 tasks across 7 phases

View File

@ -0,0 +1,19 @@
# Plan: Inventory links support non-UUID IDs
**Branch**: `079-inventory-links-non-uuid-ids`
**Date**: 2026-02-07
## Approach
- Add a PostgreSQL migration to change `inventory_links.source_id` and `inventory_links.target_id` from `uuid` to `text`.
- Add a pgsql-specific test that asserts the column types are `text` and that upserting an edge with a non-UUID `target_id` does not error.
## Safety
- Change is limited to `inventory_links` columns only.
- Unique constraint and indexes continue to function on `text` columns.
## Testing
- Pest feature test under `tests/Feature/Inventory/`.
- Run focused test + existing inventory extraction tests.

View File

@ -0,0 +1,22 @@
# Spec 079: Inventory links support non-UUID IDs
**Date**: 2026-02-07
## Problem
Inventory dependency extraction writes edges into `inventory_links`. Some Microsoft Graph / Intune identifiers (notably scope tag IDs) can be non-UUID strings (e.g. `"0"`). The current schema defines `inventory_links.source_id` and `inventory_links.target_id` as UUID columns, causing PostgreSQL failures when non-UUID identifiers are inserted.
## Goal
Allow storing non-UUID identifiers in `inventory_links` without crashing inventory sync/extraction.
## Requirements
- `inventory_links.source_id` and `inventory_links.target_id` must accept arbitrary string identifiers.
- Existing UUID identifiers must continue to work.
- Behavior must be covered by tests.
## Non-goals
- No redesign of the dependency graph model.
- No UI/Filament changes.

View File

@ -0,0 +1,18 @@
# Tasks: Inventory links support non-UUID IDs
## Phase 1: Spec + setup
- [X] T001 Create spec folder and docs
## Phase 2: Tests (TDD)
- [X] T002 Add pgsql schema regression test in `tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
## Phase 3: Implementation
- [X] T003 Add migration to change `inventory_links.source_id` + `target_id` to `text` on PostgreSQL
## Phase 4: Validation
- [X] T004 Run tests: `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
- [X] T005 Run Pint: `vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,45 @@
openapi: 3.0.3
info:
title: TenantPilot Admin Context APIs (Spec 080)
version: 0.1.0
description: |
Minimal HTTP contract for non-Filament endpoints involved in workspace/tenant context selection.
Filament page/resource routes are not fully described here because they are generated by Filament.
The specs primary contract for those is the route map in `routes.md`.
paths:
/admin/switch-workspace:
post:
summary: Switch the active workspace context
responses:
'204': { description: Workspace switched }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
'404': { description: Not a workspace member (deny-as-not-found) }
/admin/select-tenant:
post:
summary: Select the active tenant context within the selected workspace
responses:
'204': { description: Tenant selected }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
'404': { description: Not entitled to tenant (deny-as-not-found) }
/admin/clear-tenant-context:
post:
summary: Clear the active tenant context
responses:
'204': { description: Tenant context cleared }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
components:
securitySchemes:
SessionAuth:
type: apiKey
in: cookie
name: tenantpilot_session
security:
- SessionAuth: []

View File

@ -0,0 +1,52 @@
# Route Contract — Spec 080
This document defines the **expected user-facing route surfaces** and the **required 404/403 semantics**.
## Canonical Management (workspace-scoped)
All of the following are under `/admin/*` and require:
- selected workspace context
- workspace membership (non-member → 404)
Routes:
- `GET /admin/tenants`
- `GET /admin/tenants/{tenant}`
- `GET /admin/tenants/{tenant}/memberships`
- `GET /admin/tenants/{tenant}/provider-connections`
- `GET /admin/tenants/{tenant}/provider-connections/{connection}/edit`
- `GET /admin/tenants/{tenant}/required-permissions`
- (optional) `GET /admin/tenants/{tenant}/onboarding`
Identifier contract:
- `{tenant}` MUST be `Tenant.external_id` (Entra tenant GUID)
Authorization contract:
- member without capability:
- viewing pages: allowed
- mutating actions: 403
## Canonical Operate (tenant-scoped)
All of the following are under `/admin/t/{tenant}/*` and require:
- selected workspace context
- workspace membership
- tenant entitlement (non-entitled → 404)
Routes (contract targets for US2 tests):
- `GET /admin/t/{tenant}` (tenant dashboard root)
- `GET /admin/t/{tenant}/diagnostics` (operational diagnostics page)
## Removed Tenant-Scoped Management (must 404)
The following routes MUST NOT exist (no redirects in dev stage):
- `GET /admin/t/{tenant}/provider-connections*`
- `GET /admin/t/{tenant}/required-permissions*`
- `GET /admin/t/{tenant}/memberships*`
- `GET /admin/t/{tenant}/tenants*`
## Monitoring
- `GET /admin/operations`
- `GET /admin/operations/{run}`
Monitoring pages are DB-only at render time.

View File

@ -0,0 +1,71 @@
# Data Model — Spec 080 Workspace-Managed Tenant Administration Migration
This feature is primarily a **routing + panel registration** change. No new entities are required, but the plan relies on these existing domain objects and their relationships.
## Entities
### Workspace
- Represents the portfolio/customer context.
- Key fields (typical): `id`, `name`, `slug` or `uuid`, `archived_at`, timestamps.
### WorkspaceMembership
- Joins a `User` to a `Workspace` with a role.
- Key fields: `id`, `workspace_id`, `user_id`, `role`, timestamps.
- Rules:
- Workspace membership is an isolation boundary for `/admin/*` management.
### Tenant (Managed Tenant)
- Workspace-owned representation of an Entra/Intune tenant.
- Key fields (from usage in the codebase):
- `id`
- `workspace_id`
- `external_id` (canonical route identifier; Entra tenant GUID)
- `tenant_id` (Entra tenant ID / GUID — may be same domain meaning depending on model)
- `name`, `domain`, `environment`
- `metadata` (JSON)
- `archived_at` (if supported)
- timestamps
- Notes:
- `{tenant}` route parameter refers to `Tenant.external_id` in both `/admin/tenants/{tenant}` and `/admin/t/{tenant}`.
### TenantMembership
- Joins a `User` to a `Tenant` with a tenant role.
- Key fields: `id`, `tenant_id`, `user_id`, `role`, timestamps.
- Rules:
- Tenant membership is an isolation boundary for `/admin/t/{tenant}/*`.
- Guardrails: cannot remove/demote the last Owner (existing rule in constitution and code).
### ProviderConnection
- Stores provider integration configuration for a managed tenant.
- Key fields (from resource usage):
- `id`, `workspace_id`, `tenant_id`
- `provider`
- `display_name`
- `entra_tenant_id`
- `is_default`
- `status`, `health_status`
- timestamps
- Notes:
- Treated as workspace-managed configuration, but scoped to a specific managed tenant via FK.
### AuditLog
- Append-only record of security/management events.
- Required attributes (per spec): `workspace_id`, `tenant_id`, `actor_id`, `action_id`, redacted metadata, timestamp.
### OperationRun
- Existing observability record for long-running operations.
- This migration itself should not introduce new runs; management page renders must be DB-only.
## Relationships (high level)
- Workspace 1—* WorkspaceMembership
- Workspace 1—* Tenant
- Tenant 1—* TenantMembership
- Tenant 1—* ProviderConnection
- Workspace 1—* ProviderConnection
- Workspace/Tenant 1—* AuditLog
## State & Transitions
- This feature does not add new domain state transitions.
- Any existing onboarding/activation state changes remain workspace-managed in UI (per spec) and must continue to be audited.

View File

@ -0,0 +1,188 @@
# Implementation Plan: Spec 080 Workspace-Managed Tenant Administration Migration
**Branch**: `080-workspace-managed-tenant-admin` | **Date**: 2026-02-07 | **Spec**: [/specs/080-workspace-managed-tenant-admin/spec.md](spec.md)
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Migrate tenant administration surfaces out of tenant scope (`/admin/t/{tenant}/*`) into workspace scope (`/admin/*`) and keep tenant scope strictly for operational modules.
Implementation strategy:
- Introduce a second Filament panel for tenant operations at `/admin/t/{tenant}`.
- Convert the existing admin panel at `/admin` into a tenantless workspace management panel.
- Register management resources/pages only in the workspace panel, ensuring tenant-scoped management routes are not registered (404).
- Rewire internal CTAs/links (onboarding, required permissions, provider connection edit) to the new canonical workspace routes.
## Technical Context
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Tailwind v4
**Storage**: PostgreSQL (via Sail)
**Testing**: Pest v4 (PHPUnit v12 runner)
**Target Platform**: Web application (server-rendered Filament/Livewire)
**Project Type**: Laravel monolith
**Performance Goals**: No new performance targets; management viewers must remain DB-only at render time
**Constraints**:
- No external calls during render for management viewers (DB-only).
- Dev-stage removed routes must 404 (no redirects).
- 404 vs 403 semantics must follow RBAC-UX.
**Scale/Scope**: Enterprise SaaS IA separation across admin surfaces (route + navigation correctness)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
Assessment (pre-Phase 0): PASS
- No new Graph calls are introduced by this migration.
- No new long-running operations are introduced.
- Authorization behavior is being tightened via panel separation and route registration.
- Monitoring/management viewers remain DB-only.
Notes:
- This feature changes how routes are registered (which affects discovery, global search, and navigation). It must include regression tests ensuring removed tenant-scoped management routes do not exist.
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── 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)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Workspaces/
│ │ ├── Monitoring/
│ │ └── …
│ ├── Resources/
│ └── Concerns/
├── Providers/
│ └── Filament/
│ ├── AdminPanelProvider.php
│ ├── TenantPanelProvider.php
│ └── SystemPanelProvider.php
├── Support/
│ └── Middleware/
routes/
└── web.php
tests/
├── Feature/
└── Unit/
bootstrap/
└── providers.php
```
**Structure Decision**: Laravel monolith. Panel providers define panel boundaries. Routes outside Filament are defined in `routes/web.php`.
## Complexity Tracking
No constitution violations requiring justification.
## Phase 0 — Outline & Research
Outputs (written during `/speckit.plan`):
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/research.md`
Research questions (all resolved):
- How to configure a tenancy panel whose URLs are `/admin/t/{tenant}` without duplicating `/t/`.
- How to enforce route removal: do not register resources/pages in the tenant panel.
- How to keep global search isolated by panel registration.
## Phase 1 — Design & Contracts
Outputs:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/contracts/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/quickstart.md`
### Panel Design
**Workspace panel (Manage)**
- ID: `admin` (keep existing ID to avoid breaking `panel:admin` middleware usage)
- Path: `/admin`
- Tenancy: disabled (no `->tenant(...)`)
- Registered artifacts: tenant management resources/pages, monitoring pages.
**Tenant panel (Operate)**
- ID: `tenant` (new)
- Path: `/admin/t`
- Tenancy: enabled via `Tenant::class` with `slugAttribute: 'external_id'`
- Tenant route prefix: blank/`null` so tenant routes become `/admin/t/{tenant}/...`
- Registered artifacts: operational resources/pages only (inventory, drift, backups, policies, directory, etc.).
- Route-shape verification is mandatory: automated regression checks must assert canonical tenant URLs are `/admin/t/{tenant}` and `/admin/t/{tenant}/...` (never `/admin/t/t/{tenant}`).
Laravel 11+ provider registration requirement:
- Register the new tenant panel provider in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/bootstrap/providers.php`.
### Routing/Removal Mechanism
Tenant-scoped management routes return 404 by construction:
- Management resources/pages are **not registered** in the tenant panel.
- No redirects (dev-stage).
### Authorization Design
- Workspace management pages: require selected workspace + membership; non-member → 404.
- Tenant operational routes: require workspace membership + tenant entitlement; non-entitled → 404.
- Mutations: capability missing → 403 (server-side policy/gate); destructive-like actions require `->requiresConfirmation()`.
### Global Search Design
- Workspace panel: managed tenants are searchable (ensure resource has Edit/View page).
- Tenant panel: tenant-management entities are not registered, so they cannot appear in global search.
## Phase 2 — Implementation Plan (Code + Tests)
Stop condition for `/speckit.plan`: this section outlines implementation, but actual task breakdown happens in `/speckit.tasks`.
Planned steps:
1. Add new panel provider for tenant operations (e.g., `App\Providers\Filament\TenantPanelProvider`).
2. Register provider in `bootstrap/providers.php` (Laravel 11+ pattern).
3. Refactor `AdminPanelProvider` into a tenantless workspace panel:
- remove tenancy configuration (`->tenant(...)`, tenant menu, tenant route prefix)
- remove tenant-only middleware from the workspace panel pipeline
4. Move/register management pages/resources into workspace panel:
- `TenantResource` (managed tenants CRUD / manage view)
- `ProviderConnectionResource` (workspace-managed connections by tenant)
- required permissions viewer page
- membership management surfaces
- onboarding/activation surfaces
5. Move/register operational pages/resources into the tenant panel.
6. Rewire internal links/CTAs that currently build tenant-scoped management URLs to the new workspace-managed canonical URLs.
7. Add regression tests (Pest) to cover:
- workspace member can access `/admin/tenants*`
- non-member gets 404
- tenant entitlement required for `/admin/t/{tenant}/...`
- canonical tenant panel route shape is `/admin/t/{tenant}/...` (no duplicated `/t`)
- tenant-scoped management routes are missing (404)
- link rewiring expectations (where feasible)
8. Run targeted test pack and Pint:
- `vendor/bin/sail artisan test --compact tests/Feature/...`
- `vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,36 @@
# Quickstart — Spec 080 Workspace-Managed Tenant Administration Migration
## Prereqs
- Laravel Sail is used for local dev.
## Run locally
- Start services: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
## What to verify manually
1. Select a workspace (existing flow)
2. Visit workspace-managed tenant admin:
- `/admin/tenants`
- `/admin/tenants/{tenant}`
3. Visit tenant operate routes only when entitled:
- `/admin/t/{tenant}/…`
4. Confirm removed tenant-scoped management URLs return 404:
- `/admin/t/{tenant}/provider-connections`
- `/admin/t/{tenant}/required-permissions`
## Run targeted tests
- Run Spec 080 test file (to be created in Phase 2):
- `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php`
## Formatting
- Format touched files: `vendor/bin/sail bin pint --dirty`
## Deployment note
This feature changes route registration via Filament panel providers.
No migrations are expected.

View File

@ -0,0 +1,45 @@
# Research — Spec 080 Workspace-Managed Tenant Administration Migration
Date: 2026-02-07
## Decision 1 — Two Filament panels (workspace + tenant)
- Decision: Implement a workspace (tenantless) panel at `/admin` and a tenant (tenancy) panel at `/admin/t/{tenant}`.
- Rationale: This makes “Manage vs Operate” enforceable via route registration (removed routes 404), avoids tenant-context chicken-and-egg, and matches Filament-native separation.
- Alternatives considered:
- Keep a single panel and conditionally hide resources when tenant is selected: rejected because routes still exist and semantics are harder to enforce.
## Decision 2 — Tenant panel path configuration to achieve `/admin/t/{tenant}`
- Decision: Configure the tenant panel with `path('admin/t')`, `tenant(Tenant::class, slugAttribute: 'external_id')`, and **no tenant route prefix** (`tenantRoutePrefix(null)` / default).
- Rationale: Filaments tenancy routing adds `/{tenant}` after the panel path, and the optional `tenantRoutePrefix` is only prepended when it is “filled”. Leaving it blank yields `/admin/t/{tenant}` (not `/admin/t/t/{tenant}`).
- Alternatives considered:
- Keep the existing `path('admin') + tenantRoutePrefix('t')` for a tenant panel: rejected because it would conflict with the workspace panel at the same path.
## Decision 3 — Workspace context in URLs
- Decision: Workspace-managed tenant management uses `/admin/tenants*` (workspace selected in session/context; enforced by middleware).
- Rationale: Matches current app pattern (`ensure-workspace-selected`) and reduces URL churn.
- Alternatives considered:
- `/admin/w/{workspace}/tenants*`: rejected because its not canonical for this feature and increases link surface.
## Decision 4 — View vs mutation authorization in management scope
- Decision: Management pages are viewable for workspace members; **mutations** are capability-gated (403).
- Rationale: Aligns with RBAC-UX guidance: membership is isolation (404), capability is authorization (403).
- Alternatives considered:
- Require capability to view: rejected to avoid “mysterious forbidden” UX and because spec explicitly reserves 403 for mutations.
## Decision 5 — Global search isolation
- Decision: Managed tenants are searchable in the workspace panel only; the tenant panel must not expose tenant-management entities via global search.
- Rationale: Prevents cross-scope discovery leaks and aligns with “Manage is workspace-scoped”.
- Alternatives considered:
- Disable global search entirely: rejected (spec wants workspace search behavior).
## Decision 6 — How removed tenant-scoped management routes become 404
- Decision: Do not register tenant-management resources/pages in the tenant panel.
- Rationale: In Filament, unregistered resources/pages simply do not have routes; this is the cleanest dev-stage “no redirects” behavior.
- Alternatives considered:
- Redirect legacy tenant-scoped routes: rejected (explicit non-goal).

View File

@ -0,0 +1,238 @@
# Feature Specification: Workspace-Managed Tenant Administration Migration
**Feature Branch**: `080-workspace-managed-tenant-admin`
**Created**: 2026-02-07
**Status**: Draft (implementation-ready)
**Input**: User description: "Make Manage workspace-scoped (/admin) and Operate tenant-scoped (/admin/t/{tenant}). Eliminate management CRUD from /admin/t/*"
This feature migrates all tenant administration surfaces out of the Filament tenant scope (`/admin/t/{tenant}/*`) into workspace-scoped routes (`/admin/*`). Tenant scope is reserved strictly for operational modules.
**Separation rule (normative):**
- Workspace panel (`/admin/*`) = Manage (tenants, memberships, provider connections, required permissions, onboarding/activation, monitoring).
- Tenant panel (`/admin/t/{tenant}/*`) = Operate (inventory, drift, policies, backups, directory, etc.).
**Out of scope for this feature:** introducing a new tenant panel “Home” page at `/admin/t/{tenant}`.
## Clarifications
### Session 2026-02-07
- Q: Should management pages be viewable for workspace members without manage capabilities? → A: Yes. Management pages are viewable for workspace members; only mutations are capability-gated (403).
- Q: Should this feature include a dedicated tenant panel “Home” page at `/admin/t/{tenant}`? → A: No. Do not add a new tenant home page in this feature.
- Q: For workspace-managed routes like `/admin/tenants/{tenant}`, what should `{tenant}` be? → A: `Tenant.external_id` (Entra tenant GUID), same identifier used by Filament tenancy under `/admin/t/{tenant}`.
- Q: Should “Managed Tenants” appear in Filament Global Search? → A: Yes, in the workspace panel only; tenant panel must not expose tenant-management entities in search.
- Q: For workspace-managed tenant management routes, what is the canonical URL shape? → A: `/admin/tenants*` (workspace is selected in context/session; middleware enforces it), not `/admin/w/{workspace}/tenants*`.
## 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 - Manage tenants from workspace scope (Priority: P1)
As a workspace member, I can manage (view/create/configure) managed tenants under `/admin/tenants*` without needing to enter tenant scope.
**Why this priority**: It removes the “henne-ei” context issue and makes `/admin` the canonical management surface.
**Independent Test**: Fully testable via HTTP requests asserting 200/404 and basic page visibility under `/admin/tenants*`.
**Acceptance Scenarios**:
1. **Given** I am a workspace member, **When** I visit `/admin/tenants`, **Then** I can access the Tenants management list.
2. **Given** I am a workspace member, **When** I visit `/admin/tenants/{tenant}`, **Then** I can access the Tenant management overview.
3. **Given** I am not a workspace member, **When** I visit `/admin/tenants` or `/admin/tenants/{tenant}`, **Then** I receive a 404 (deny-as-not-found).
---
### User Story 2 - Operate tenant modules only when entitled (Priority: P2)
As a user, I can operate inside a managed tenant under `/admin/t/{tenant}/*` only when Im entitled to that tenant.
**Why this priority**: It preserves tenant isolation and makes 404/403 semantics predictable.
**Independent Test**: Fully testable via an operational page route asserting 200 for entitled users and 404 for non-entitled users.
**Acceptance Scenarios**:
1. **Given** I am a workspace member and entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I can access it.
2. **Given** I am a workspace member but not entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I receive a 404 (deny-as-not-found).
---
### User Story 3 - Tenant-scoped management routes are removed (Priority: P3)
As a user, I cannot access tenant-scoped management CRUD routes anymore; they are removed and should not resolve.
**Why this priority**: It enforces IA separation via route registration (not just UI hiding).
**Independent Test**: Fully testable via HTTP 404 assertions for a set of removed routes.
**Acceptance Scenarios**:
1. **Given** any user, **When** I request `/admin/t/{tenant}/provider-connections`, **Then** I receive 404 because the route does not exist.
2. **Given** any user, **When** I request `/admin/t/{tenant}/required-permissions`, **Then** I receive 404 because the route does not exist.
3. **Given** any user, **When** I request a tenant-scoped tenant-management route (e.g. `/admin/t/{tenant}/tenants/*`), **Then** I receive 404 because the route does not exist.
---
### User Story 4 - Management actions enforce capability semantics (Priority: P2)
As a workspace member, I can see management pages, but mutations are forbidden unless I have the required capability.
**Why this priority**: It preserves enterprise RBAC semantics (404 for non-membership; 403 for missing capability on mutation).
**Independent Test**: Test a representative management mutation and assert 403 when capability is missing.
**Acceptance Scenarios**:
1. **Given** I am a workspace member without the relevant manage capability, **When** I attempt a management mutation (e.g., change role, set default connection), **Then** the server responds 403.
---
### User Story 5 - Global search isolation (Priority: P2)
As a user, global search does not leak tenant management entities across scopes.
**Why this priority**: It prevents discovery leaks and aligns with deny-as-not-found semantics.
**Independent Test**: Test that workspace global search can find allowed tenants; tenant panel search does not expose tenant-management entities; non-members discover nothing.
**Acceptance Scenarios**:
1. **Given** I am a workspace member, **When** I use workspace global search, **Then** I can find tenants I can access.
2. **Given** I am in tenant panel, **When** I use global search, **Then** it does not expose tenant-management entities.
3. **Given** I am not a workspace member, **When** I use global search, **Then** I do not discover tenant existence.
---
### Edge Cases
- Direct navigation to removed tenant-scoped management URLs.
- Stale internal CTAs/links that previously pointed at `/admin/t/{tenant}` management screens.
- Tenant slug/identifier mismatch (requesting a tenant not belonging to the active workspace context).
- Cross-scope leakage via global search results or navigation items.
- Non-member users attempting to infer tenant/workspace existence.
## 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.
### Functional Requirements
**Principles (normative):**
- “Tenants” means the workspace-managed tenants list (CRUD under `/admin/tenants*`).
- “Switch tenant” is a context action in tenant scope; it must not behave like a CRUD list.
- Management must not depend on being in tenant scope; tenant scope must not expose tenant administration CRUD.
**Routing / IA (normative):**
- Workspace management is canonical under `/admin/*`:
- `/admin/tenants` (list/create)
- `/admin/tenants/{tenant}` (manage overview)
- `/admin/tenants/{tenant}/memberships`
- `/admin/tenants/{tenant}/provider-connections`
- `/admin/tenants/{tenant}/required-permissions`
- `/admin/tenants/{tenant}/onboarding` (optional entry)
- `/admin/operations` and `/admin/operations/{run}` (monitoring)
- Tenant scope is operate-only under `/admin/t/{tenant}/*` (inventory, drift, policies, backups/restore, directory, etc.).
**Workspace context:**
- Workspace-scoped management routes MUST use the `/admin/tenants*` shape and rely on the selected workspace context (e.g., middleware) rather than including `{workspace}` in every management URL.
**Route parameter identity (normative):**
- `{tenant}` in both workspace-managed routes (`/admin/tenants/{tenant}/*`) and tenant-scoped routes (`/admin/t/{tenant}/*`) MUST refer to the managed tenant identifier `Tenant.external_id` (Entra tenant GUID).
**Authorization semantics (mandatory):**
- Non-member / not entitled to scope: 404 (deny-as-not-found).
- Member lacking capability: 403 for management mutations.
- Workspace management pages are viewable for workspace members even without manage capabilities; only mutations are capability-gated.
- All checks are server-side via Policies/Gates, using the canonical capability registry (no raw strings).
**Panel structure (native):**
- Two Filament panels are used:
- Workspace panel is tenantless and mounted at `/admin`.
- Tenant panel is tenancy-mounted at `/admin/t/{tenant}`.
- Each resource/page is registered only in the appropriate panel.
**Global search isolation (mandatory):**
- Global search behavior is defined normatively in **FR-080-014**; this section captures intent only (no cross-scope discovery leaks).
**Route removal (dev-stage requirement):**
- Tenant-scoped management routes must not exist after migration (expected 404):
- `/admin/t/{tenant}/provider-connections*`
- `/admin/t/{tenant}/required-permissions*`
- `/admin/t/{tenant}/memberships*`
- `/admin/t/{tenant}/tenants*` (any management tenancy list/view/edit)
**FR-080-001**: The system MUST expose tenant administration surfaces only under workspace scope (`/admin/tenants*`).
**FR-080-002**: The system MUST expose tenant operations only under tenant scope (`/admin/t/{tenant}/*`).
**FR-080-003**: All workspace management pages MUST require active workspace context + workspace membership; non-member returns 404.
**FR-080-004**: All tenant operational routes MUST require workspace membership + tenant entitlement; non-entitled returns 404.
**FR-080-005**: Management mutations MUST return 403 when capability is missing (in addition to any UI disabling).
**Management surfaces (workspace-scoped):**
**FR-080-006**: Tenants list and tenant manage overview MUST exist under `/admin/tenants` and `/admin/tenants/{tenant}`.
**FR-080-007**: Provider Connections CRUD MUST exist only under `/admin/tenants/{tenant}/provider-connections*`.
**FR-080-008**: Required Permissions remediation UI MUST exist only under `/admin/tenants/{tenant}/required-permissions` and render DB-only.
**FR-080-009**: Tenant Memberships/Roles management MUST exist only under `/admin/tenants/{tenant}/memberships` and audit changes.
**FR-080-010**: Activation/onboarding controls MUST live under workspace-managed tenant pages/wizard entry (not in tenant scope).
**Navigation (enterprise):**
**FR-080-011**: Workspace navigation MUST include Tenants (manage) and Monitoring (Operations, Alerts, Audit Log).
**FR-080-012**: Tenant navigation MUST include only operational modules; no tenant CRUD entry appears in tenant sidebar.
**FR-080-014**: Workspace panel global search MAY return managed tenants only when the user can access them; tenant panel global search MUST NOT include tenant-management resources.
**FR-080-015**: Workspace-managed tenant management routes MUST be reachable under `/admin/tenants*` with a selected workspace context; `/admin/w/{workspace}` is not the canonical management route shape for this feature.
**Filament constraint (hard rule):** any globally searchable Resource MUST have an Edit or View page; otherwise global search will return no results.
**Observability & Audit (minimal, mandatory):**
**FR-080-013**: The system MUST emit audit events for management mutations (tenant changes, role changes, provider connection CRUD/default selection, activation/onboarding state changes) with redacted fields only.
### Key Entities *(include if feature involves data)*
- **Workspace**: Portfolio/customer context; primary security boundary for `/admin/*`.
- **ManagedTenant**: Workspace-owned representation of an Entra/Intune tenant (identified by Entra tenant GUID).
- **TenantMembership**: Assignment of a user to a managed tenant with a role (Owner/Manager/Operator/Readonly).
- **ProviderConnection**: Stored connection/config for provider integration, scoped to a managed tenant.
- **Capability**: Canonical capability registry entries used for authorization decisions.
- **AuditLog entry**: Append-only event record for management mutations (redacted).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-080-001**: Tenant management surfaces are reachable only under `/admin/tenants*`.
- **SC-080-002**: Tenant operational surfaces are reachable only under `/admin/t/{tenant}/*`.
- **SC-080-003**: Tenant-scoped management routes are not registered and return 404.
- **SC-080-004**: Authorization semantics are consistent: non-member 404, missing capability yields 403 on mutations.
- **SC-080-005**: Internal CTAs/links to manage provider connections/required permissions point to workspace-managed routes.

View File

@ -0,0 +1,200 @@
---
description: "Task breakdown for Spec 080 implementation"
---
# Tasks: Workspace-Managed Tenant Administration Migration (Spec 080)
**Input**: Design documents from `/specs/080-workspace-managed-tenant-admin/` (`plan.md`, `spec.md`, `contracts/routes.md`, `research.md`, `data-model.md`, `quickstart.md`)
**Non-negotiables (repo rules)**
- Filament v5 + Livewire v4.0+ only.
- Laravel 11+: Filament panel providers are registered in `bootstrap/providers.php`.
- RBAC-UX semantics: non-member/non-entitled → 404; member missing capability on mutation → 403.
- Removed tenant-scoped management routes must not be registered (404, no redirects).
- Tests are REQUIRED (Pest).
## Phase 1: Setup (Panel Scaffolding)
**Purpose**: Introduce the tenant operations panel without changing behavior yet.
- [X] T001 Create tenant operations panel provider in app/Providers/Filament/TenantPanelProvider.php
- [X] T002 Register new provider in bootstrap/providers.php (add App\Providers\Filament\TenantPanelProvider::class)
**Checkpoint**: App boots with both panels registered.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the manage vs operate separation mechanisms (routing + middleware + discovery boundaries).
- [X] T003 Refactor workspace panel to be tenantless in app/Providers/Filament/AdminPanelProvider.php (remove ->tenant(...), ->tenantRoutePrefix('t'), tenant menu/searchable menu)
- [X] T004 Update workspace panel middleware stack in app/Providers/Filament/AdminPanelProvider.php (remove ensure-filament-tenant-selected and App\Support\Middleware\DenyNonMemberTenantAccess for workspace panel)
- [X] T005 Configure tenant panel tenancy + middleware in app/Providers/Filament/TenantPanelProvider.php (enable ->tenant(App\Models\Tenant::class, slugAttribute: 'external_id'), enforce canonical path `/admin/t/{tenant}/...`, and add ensure-filament-tenant-selected + DenyNonMemberTenantAccess)
- [X] T006 Constrain resource/page discovery so management artifacts do not get registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (use dedicated discovery roots like app/Filament/Tenant/**)
- [X] T007 [P] Add a dedicated base feature test file tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (placeholder tests + factories usage notes)
- [X] T030 [P] [FOUNDATION] Add route-shape regression tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php asserting tenant routes resolve as `/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics` and never `/admin/t/t/{tenant}/...`
**Checkpoint**: Workspace panel does not require tenant context; tenant panel is isolated by discovery roots.
---
## Phase 3: User Story 1 — Manage tenants from workspace scope (Priority: P1) 🎯 MVP
**Goal**: Tenants management is canonical under `/admin/tenants*` without needing tenant scope.
**Independent Test**: As a workspace member, `GET /admin/tenants` returns 200; non-member returns 404.
### Tests (Pest) — US1
- [X] T008 [P] [US1] Add access tests for workspace-managed tenant list in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
- [X] T009 [P] [US1] Add access tests for workspace-managed tenant view route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
- [X] T031 [P] [US1] Add access tests for workspace-managed memberships route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/tenants/{tenant}/memberships`, member 200, non-member 404)
### Implementation — US1
- [X] T010 [US1] Move/rename management routes to match /admin/tenants* in app/Filament/Resources/TenantResource.php (ensure resource slug becomes tenants under workspace panel)
- [X] T011 [US1] Ensure TenantResource is registered only in workspace panel (update app/Providers/Filament/AdminPanelProvider.php registration/discovery strategy)
- [X] T012 [US1] Ensure tenant route parameter identity uses Tenant.external_id in app/Models/Tenant.php (route key name) OR in TenantResource route binding configuration
- [X] T032 [US1] Ensure memberships management surface exists only under workspace scope in app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php and app/Filament/Resources/TenantResource.php (`/admin/tenants/{tenant}/memberships`)
**Checkpoint**: `/admin/tenants` works in the workspace panel.
---
## Phase 4: User Story 2 — Operate inside tenant scope only when entitled (Priority: P2)
**Goal**: Operational pages remain under `/admin/t/{tenant}/*` and return 404 when user is not entitled.
**Independent Test**: Entitled user can access `GET /admin/t/{tenant}` and `GET /admin/t/{tenant}/diagnostics`; non-entitled user gets 404.
### Tests (Pest) — US2
- [X] T013 [P] [US2] Add tenant entitlement tests for concrete operational routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics`: entitled 200, non-entitled 404)
### Implementation — US2
- [X] T014 [US2] Register/relocate tenant operational dashboard page into tenant panel in app/Filament/Pages/TenantDashboard.php and app/Providers/Filament/TenantPanelProvider.php
- [X] T015 [US2] Ensure tenant selection redirects into tenant panel routes in app/Filament/Pages/ChooseTenant.php (redirect should target /admin/t/{tenant}/...)
**Checkpoint**: The contracted operational routes are reachable only when entitled.
---
## Phase 5: User Story 3 — Remove tenant-scoped management CRUD routes (Priority: P3)
**Goal**: Tenant-scoped management URLs do not resolve because resources/pages are not registered in tenant panel.
**Independent Test**: Requests to removed routes return 404 (route does not exist).
### Tests (Pest) — US3
- [X] T016 [P] [US3] Add 404 regression tests for removed routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (e.g. /admin/t/{tenant}/provider-connections, /admin/t/{tenant}/required-permissions)
- [X] T033 [P] [US3] Add tenant navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php ensuring tenant panel sidebar does not expose tenant-management entries (Tenants, Provider Connections, Memberships)
### Implementation — US3
- [X] T017 [US3] Ensure management resources are not discovered/registered in tenant panel (verify TenantResource + ProviderConnectionResource not in tenant discovery roots in app/Providers/Filament/TenantPanelProvider.php)
- [X] T018 [US3] Ensure required permissions page is not registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (and/or relocate page class under workspace-managed location)
- [X] T034 [US3] Ensure tenant panel navigation is operate-only by construction in app/Providers/Filament/TenantPanelProvider.php (no TenantResource/ProviderConnectionResource/TenantMemberships registration)
**Checkpoint**: Removed tenant-scoped management paths return 404 (no redirects).
---
## Phase 6: User Story 4 — Management actions enforce capability semantics (Priority: P2)
**Goal**: Workspace members can view management pages; mutations are forbidden (403) unless capability is present.
**Independent Test**: A representative management mutation returns 403 for a workspace member missing capability.
### Tests (Pest) — US4
- [X] T019 [P] [US4] Add mutation authorization test asserting 403 (member missing capability) in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- [X] T035 [P] [US4] Add audit assertion test for tenant membership mutation in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (writes redacted AuditLog with stable action ID)
### Implementation — US4
- [X] T020 [US4] Audit management mutations in app/Services/Intune/AuditLogger.php (or existing audit service) for provider connection + membership changes with stable action IDs
- [X] T021 [US4] Ensure destructive-like actions use ->requiresConfirmation() in app/Filament/Resources/TenantResource.php and app/Filament/Resources/ProviderConnectionResource.php
**Checkpoint**: 403 vs 404 semantics match spec for mutations.
---
## Phase 7: User Story 5 — Global search isolation (Priority: P2)
**Goal**: Tenant management entities are searchable only in workspace panel; tenant panel global search does not expose them.
**Independent Test**: Workspace panel global search can resolve TenantResource results; tenant panel global search does not include TenantResource/ProviderConnectionResource.
### Tests (Pest) — US5
- [X] T022 [P] [US5] Add global search scoping tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (workspace panel finds tenant; tenant panel does not expose management resources)
### Implementation — US5
- [X] T023 [US5] Ensure TenantResource has a View/Edit page for global search compliance in app/Filament/Resources/TenantResource/Pages/*
- [X] T024 [US5] Ensure tenant panel does not register tenant-management resources/pages (verify discovery roots + resource registration in app/Providers/Filament/TenantPanelProvider.php)
**Checkpoint**: Global search results do not leak across scopes.
---
## Phase 8: Polish & Cross-Cutting
**Purpose**: Link rewiring, consistency, formatting, and minimal regression validation.
- [X] T025 [P] Rewire internal CTAs from tenant-scoped management URLs to workspace-managed routes in app/Filament/Pages/Workspaces/ManagedTenantsLanding.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [X] T026 [P] Update required permissions viewer to be workspace-managed + DB-only in app/Filament/Pages/TenantRequiredPermissions.php (accept tenant via route param, avoid Tenant::current())
- [X] T027 [P] Update provider connections to support workspace-managed per-tenant routes in app/Filament/Resources/ProviderConnectionResource.php (query should use route tenant param when not in tenancy)
- [X] T038 [P] Add Provider Connections CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (links to /admin/tenants/{tenant}/provider-connections)
- [X] T036 [P] Add workspace navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php to assert Tenants + Monitoring entries are present in workspace panel after panel split
- [X] T037 [P] Extend RBAC regression coverage for panel boundaries in tests/Feature/TenantRBAC/* (deny-as-not-found across workspace/tenant routing boundaries)
- [X] T028 Run formatting on touched files with vendor/bin/sail bin pint --dirty (formats app/** and tests/**)
- [X] T029 Run focused tests with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- [X] T039 Run formatting on touched files again (post-T038) with vendor/bin/sail bin pint --dirty
- [X] T040 Re-run focused spec tests (post-T038) with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
---
## Dependencies & Execution Order
### User Story Dependency Graph
- Setup (Phase 1) → Foundational (Phase 2) → US1 (MVP)
- US2 depends on Phase 2
- US3 depends on Phase 2 (route removal works once panel discovery boundaries are in place)
- US4 depends on US1 (needs workspace management routes/actions)
- US5 depends on Phase 2 + US1 (workspace management resources must exist)
- Polish tasks depend on US1US5 completion for stable regression assertions (T036, T037).
### Parallel Opportunities
- After Phase 2 completes:
- US1 tests (T008T009, T031) can be written in parallel with US1 implementation (T010T012, T032).
- US2 (T013T015) can proceed in parallel with US1.
- US3 tests (T016, T033) can be added early as regression guards.
---
## Parallel Example: US1
- Write tests: T008 + T009 in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- Implement routes: T010 in app/Filament/Resources/TenantResource.php
---
## Implementation Strategy
### MVP Scope
- Complete Phase 1 + Phase 2 + US1 (T001T012)
- Validate with T029 and manual checks from quickstart.md
### Incremental Delivery
- Add US2 + US3 next (routing guarantees + entitlement semantics)
- Then US4 (mutation semantics + audit) and US5 (global search isolation)

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class CanonicalDetailRenderTest extends TestCase
{
use RefreshDatabase;
public function test_renders_canonical_detail_for_a_workspace_member_when_tenant_context_exists(): void
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'policy.sync',
'status' => 'completed',
'outcome' => 'succeeded',
'context' => [
'target_scope' => [
'entra_tenant_name' => 'Contoso',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
],
],
'summary_counts' => [
'total' => 10,
'processed' => 10,
'succeeded' => 10,
'failed' => 0,
'skipped' => 0,
],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run')
->assertSee('Policy sync')
->assertSee('Counts')
->assertSee('Context')
->assertSee('Contoso');
}
public function test_renders_canonical_detail_gracefully_when_tenant_id_is_null(): void
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('No target scope details were recorded for this run.');
}
public function test_returns_404_on_canonical_detail_for_non_members(): void
{
$tenant = Tenant::factory()->create();
[$otherUser] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'policy.sync',
'status' => 'completed',
'outcome' => 'succeeded',
]);
$this->actingAs($otherUser)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
}
public function test_renders_canonical_detail_db_only_with_no_job_dispatch(): void
{
Bus::fake();
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
),
],
]);
assertNoOutboundHttp(function () use ($user, $run): void {
$this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Verification report');
});
Bus::assertNothingDispatched();
Queue::assertNothingPushed();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class KpiHeaderTenantlessTest extends TestCase
{
use RefreshDatabase;
public function test_hides_operations_kpi_stats_when_tenant_context_is_absent(): void
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
OperationRun::factory()->count(3)->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.index'))
->assertOk()
->assertDontSee('Total Runs (30 days)')
->assertDontSee('Failed/Partial (7 days)')
->assertDontSee('Avg Duration (7 days)');
}
}

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