fix(spec-085-086): stabilize ops UX + provider connection fixtures

This commit is contained in:
Ahmed Darrazi 2026-02-11 13:50:44 +01:00
parent c8e5996a1a
commit bd19864a42
69 changed files with 629 additions and 120 deletions

View File

@ -347,7 +347,7 @@ public function content(Schema $schema): Schema
SchemaActions::make([
Action::make('wizardStartVerification')
->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
? null
@ -662,7 +662,7 @@ private function verificationStatus(): string
continue;
}
if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied', 'provider_consent_missing'], true)) {
if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
return 'blocked';
}
}

View File

@ -26,7 +26,6 @@
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -50,7 +49,6 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@ -58,6 +56,10 @@
class BackupScheduleResource extends Resource
{
protected static ?string $model = BackupSchedule::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';

View File

@ -8,6 +8,10 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
@ -20,6 +24,16 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
protected static ?string $title = 'Executions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
}
public function table(Table $table): Table
{
return $table
@ -32,7 +46,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('type')
->label('Type')
->formatStateUsing(fn (string $state): string => OperationCatalog::label($state)),
->formatStateUsing([OperationCatalog::class, 'label']),
Tables\Columns\TextColumn::make('status')
->badge()

View File

@ -43,6 +43,8 @@ class BackupSetResource extends Resource
{
protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';

View File

@ -38,6 +38,8 @@ class FindingResource extends Resource
{
protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Drift';

View File

@ -37,6 +37,8 @@ class InventoryItemResource extends Resource
{
protected static ?string $model = InventoryItem::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 1;

View File

@ -32,6 +32,8 @@ class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $shouldRegisterNavigation = true;
protected static ?string $cluster = InventoryCluster::class;

View File

@ -52,6 +52,8 @@ class PolicyResource extends Resource
{
protected static ?string $model = Policy::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';

View File

@ -51,6 +51,8 @@ class PolicyVersionResource extends Resource
{
protected static ?string $model = PolicyVersion::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';

View File

@ -18,8 +18,8 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -93,7 +93,9 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
return Tenant::current();
$filamentTenant = \Filament\Facades\Filament::getTenant();
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
}
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
@ -772,9 +774,13 @@ public static function getEloquentQuery(): Builder
return $query->whereRaw('1 = 0');
}
if ($tenantId === null) {
return $query->whereRaw('1 = 0');
}
return $query
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->where('tenant_id', $tenantId)
->latest('id');
}
@ -792,6 +798,10 @@ public static function getPages(): array
*/
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) && blank($parameters['tenant'])) {
unset($parameters['tenant']);
}
if (! array_key_exists('tenant', $parameters)) {
if ($tenant instanceof Tenant) {
$parameters['tenant'] = $tenant->external_id;
@ -802,6 +812,20 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
$parameters['tenant'] = $resolvedTenant->external_id;
}
$record = $parameters['record'] ?? null;
if (! array_key_exists('tenant', $parameters) && $record instanceof ProviderConnection) {
$recordTenant = $record->tenant;
if (! $recordTenant instanceof Tenant) {
$recordTenant = Tenant::query()->whereKey($record->tenant_id)->first();
}
if ($recordTenant instanceof Tenant) {
$parameters['tenant'] = $recordTenant->external_id;
}
}
}
$panel ??= 'admin';

View File

@ -630,9 +630,9 @@ protected function getHeaderActions(): array
$hadCredentials = $record->credential()->exists();
$previousStatus = (string) $record->status;
$status = $hadCredentials ? 'connected' : 'error';
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
$status = $hadCredentials ? 'connected' : 'needs_consent';
$errorReasonCode = null;
$errorMessage = null;
$record->update([
'status' => $status,
@ -669,8 +669,8 @@ protected function getHeaderActions(): array
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->title('Connection enabled (needs consent)')
->body('Grant admin consent before running checks or operations.')
->warning()
->send();

View File

@ -36,6 +36,7 @@
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -60,6 +61,17 @@ class RestoreRunResource extends Resource
{
protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';

View File

@ -7,7 +7,6 @@
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\EntraGroup;
use App\Models\EntraRoleDefinition;
use App\Models\ProviderConnection;
@ -18,6 +17,7 @@
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacOnboardingService;
use App\Services\OperationRunService;
@ -50,9 +50,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class TenantResource extends Resource
@ -982,6 +980,7 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('scope') === 'scope_group')
->required(fn (Get $get) => $get('scope') === 'scope_group')
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
->noSearchResultsMessage('No security groups found')
@ -1008,6 +1007,7 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('group_mode') === 'existing')
->required(fn (Get $get) => $get('group_mode') === 'existing')
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
->noSearchResultsMessage('No security groups found')
@ -1046,8 +1046,10 @@ public static function rbacAction(): Actions\Action
abort(403);
}
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($cacheKey);
$userCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), null);
$sessionCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($sessionCacheKey) ?? Cache::get($userCacheKey);
if (! $token) {
Notification::make()
@ -1072,7 +1074,8 @@ public static function rbacAction(): Actions\Action
$result = $service->run($record, $data, $user, $token);
Cache::forget($cacheKey);
Cache::forget($sessionCacheKey);
Cache::forget($userCacheKey);
if ($result['status'] === 'success') {
Notification::make()
@ -1232,11 +1235,7 @@ public static function roleSearchHelper(?Tenant $tenant): ?string
return null;
}
$exists = EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->exists();
return $exists ? null : 'Role definitions not synced yet. Use “Sync now” to load.';
return static::delegatedToken($tenant) === null ? 'Login to search roles' : null;
}
/**
@ -1244,9 +1243,61 @@ public static function roleSearchHelper(?Tenant $tenant): ?string
*/
public static function roleSearchOptions(?Tenant $tenant, string $search): array
{
$token = static::delegatedToken($tenant);
if ($token !== null) {
return static::searchRoleDefinitionsDelegated($tenant, $search, $token);
}
return static::searchRoleDefinitions($tenant, $search);
}
/**
* @return array<string, string>
*/
private static function searchRoleDefinitionsDelegated(?Tenant $tenant, string $search, string $token): array
{
if (! $tenant || mb_strlen($search) < 2) {
return [];
}
$needle = mb_strtolower($search);
/** @var GraphClientInterface $graph */
$graph = app(GraphClientInterface::class);
$response = $graph->request('GET', 'deviceManagement/roleDefinitions', [
'access_token' => $token,
]);
if ($response->failed()) {
return [];
}
$roles = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
$results = [];
foreach ($roles as $role) {
$id = is_string($role['id'] ?? null) ? (string) $role['id'] : null;
$displayName = is_string($role['displayName'] ?? null) ? (string) $role['displayName'] : null;
if (! $id || ! $displayName) {
continue;
}
if (! str_contains(mb_strtolower($displayName), $needle)) {
continue;
}
$results[$id] = static::formatRoleLabel($displayName, $id);
}
ksort($results);
return array_slice($results, 0, 20, true);
}
/**
* @return array<string, string>
*/
@ -1314,7 +1365,11 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
public static function groupSearchHelper(?Tenant $tenant): ?string
{
return null;
if (! $tenant) {
return null;
}
return static::delegatedToken($tenant) === null ? 'Login to search groups' : null;
}
/**
@ -1326,6 +1381,42 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
return [];
}
$token = static::delegatedToken($tenant);
if ($token !== null) {
/** @var GraphClientInterface $graph */
$graph = app(GraphClientInterface::class);
$response = $graph->request('GET', 'groups', [
'access_token' => $token,
'query' => [
'$filter' => sprintf("startswith(displayName,'%s') and securityEnabled eq true", addslashes($search)),
],
]);
if ($response->failed()) {
return [];
}
$groups = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
$results = [];
foreach ($groups as $group) {
$id = is_string($group['id'] ?? null) ? (string) $group['id'] : null;
$displayName = is_string($group['displayName'] ?? null) ? (string) $group['displayName'] : null;
if (! $id || ! $displayName) {
continue;
}
$results[$id] = EntraGroupLabelResolver::formatLabel($displayName, $id);
}
ksort($results);
return array_slice($results, 0, 20, true);
}
$needle = mb_strtolower($search);
return EntraGroup::query()

View File

@ -46,7 +46,7 @@ public function handle(PolicySyncService $service, OperationRunService $operatio
{
$graph = app(GraphClientInterface::class);
if (! config('graph.enabled') || $graph instanceof NullGraphClient) {
if ($graph instanceof NullGraphClient) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,

View File

@ -5,8 +5,11 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
@ -37,6 +40,7 @@ class AdminPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('admin')
->path('admin')
->login(Login::class)
@ -44,8 +48,6 @@ public function panel(Panel $panel): Panel
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
})
->colors([
'primary' => Color::Amber,
@ -104,9 +106,15 @@ public function panel(Panel $panel): Panel
)
->resources([
TenantResource::class,
PolicyResource::class,
ProviderConnectionResource::class,
InventoryItemResource::class,
WorkspaceResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->pages([
InventoryCoverage::class,
TenantRequiredPermissions::class,
])
->widgets([

View File

@ -30,7 +30,6 @@ class TenantPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('tenant')
->path('admin/t')
->login(Login::class)

View File

@ -3,6 +3,7 @@
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
@ -10,10 +11,14 @@
use App\Services\Graph\GraphResponse;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
@ -28,6 +33,88 @@ public function __construct(
private readonly ProviderGateway $providerGateway,
) {}
/**
* Runs an inventory sync immediately and persists a corresponding InventorySyncRun.
*
* This is primarily used in tests and for synchronous workflows.
*
* @param array<string, mixed> $selectionPayload
*/
public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun
{
$computed = $this->normalizeAndHashSelection($selectionPayload);
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$operationRun = OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'System',
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()),
'context' => $normalizedSelection,
'started_at' => now(),
]);
$run = InventorySyncRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'operation_run_id' => (int) $operationRun->getKey(),
'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'started_at' => now(),
]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED);
$hadErrors = (bool) ($result['had_errors'] ?? true);
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null;
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
$run->update([
'status' => $status,
'had_errors' => $hadErrors,
'error_codes' => $errorCodes,
'error_context' => $errorContext,
'items_observed_count' => (int) ($result['items_observed_count'] ?? 0),
'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0),
'errors_count' => (int) ($result['errors_count'] ?? 0),
'finished_at' => now(),
]);
$policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$operationOutcome = match ($status) {
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
'skipped' => OperationRunOutcome::Blocked->value,
default => OperationRunOutcome::Failed->value,
};
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => $operationOutcome,
'summary_counts' => [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)),
'failed' => (int) ($result['errors_count'] ?? 0),
'items' => (int) ($result['items_observed_count'] ?? 0),
'updated' => (int) ($result['items_upserted_count'] ?? 0),
],
'completed_at' => now(),
]);
return $run->refresh();
}
/**
* Runs an inventory sync (inline), enforcing locks/concurrency.
*

View File

@ -65,7 +65,14 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenantParameter = null;
if ($request->route()?->hasParameter('tenant')) {
$tenantParameter = $request->route()->parameter('tenant');
} elseif (filled($request->query('tenant'))) {
$tenantParameter = $request->query('tenant');
}
if ($tenantParameter !== null) {
$user = $request->user();
if ($user === null) {
@ -76,12 +83,9 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
if (! $panel->hasTenancy()) {
return $next($request);
}
$tenantParameter = $request->route()->parameter('tenant');
$tenant = $panel->getTenant($tenantParameter);
$tenant = $tenantParameter instanceof Tenant
? $tenantParameter
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
if (! $tenant instanceof Tenant) {
abort(404);

View File

@ -21,7 +21,9 @@ public function requiredCapabilityForType(string $operationType): ?string
'restore.execute' => Capabilities::TENANT_MANAGE,
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
'provider.connection.check' => Capabilities::PROVIDER_RUN,
// Viewing verification reports should be possible for readonly members.
// Starting verification is separately guarded by the verification service.
'provider.connection.check' => Capabilities::PROVIDER_VIEW,
// Keep legacy / unknown types viewable by membership+entitlement only.
default => null,

View File

@ -18,7 +18,7 @@
</include>
</source>
<php>
<ini name="memory_limit" value="512M"/>
<ini name="memory_limit" value="2048M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>

View File

@ -87,7 +87,7 @@
<x-filament::dropdown.list>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
href="{{ route('filament.admin.resources.workspaces.index') }}"
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Switch workspace

View File

@ -27,7 +27,7 @@
Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start');
// Panel root override: keep the app's workspace-first flow.
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
// when no default tenant can be resolved.
Route::middleware([
@ -148,6 +148,19 @@
->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',
'ensure-filament-tenant-selected',
])
->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index'))
->name('admin.operations.legacy-tenant-index');
Route::middleware([
'web',
'panel:admin',

View File

@ -16,6 +16,8 @@
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant);
$policies = Policy::factory()
->count(3)
->create([

View File

@ -53,6 +53,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('extracts edges during inventory sync and marks missing appropriately', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
$svc = app(InventorySyncService::class);
@ -73,6 +74,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('respects 50-edge limit for outbound extraction', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
// Fake client returning 60 group assignments
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
@ -132,6 +134,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('persists unsupported reference warnings on the sync run record', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
@ -276,6 +279,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface

View File

@ -97,6 +97,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-1',
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'dcs-1',

View File

@ -58,6 +58,8 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,

View File

@ -82,6 +82,8 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
@ -163,6 +165,8 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,

View File

@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'ca-policy-1',

View File

@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'enrollment-restriction-1',
@ -159,6 +161,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'enrollment-limit-1',

View File

@ -95,6 +95,8 @@ public function request(string $method, string $path, array $options = []): Grap
'status' => 'active',
]);
ensureDefaultProviderConnection($tenant);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;

View File

@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'gpo-1',

View File

@ -54,6 +54,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'status' => 'active',
]);
ensureDefaultProviderConnection($tenant);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();

View File

@ -36,7 +36,7 @@
]);
$this->actingAs($user)
->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant)))
->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee('Policy A')
->assertDontSee('Policy B');

View File

@ -44,7 +44,7 @@
[$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$response = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings&tenant='.(string) $tenant->external_id);
$response->assertOk();
$response->assertSee('Settings');

View File

@ -71,6 +71,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'gpo-versioned-1',

View File

@ -1,7 +1,6 @@
<?php
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
@ -58,7 +57,8 @@
Livewire::test(ListPolicyVersions::class)
->callTableAction('restore_via_wizard', $version)
->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant));
->assertRedirectContains('/admin/restore-runs/create')
->assertRedirectContains('tenant='.(string) $tenant->external_id);
$backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first();
expect($backupSet)->not->toBeNull();

View File

@ -126,7 +126,7 @@
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings');
->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id);
$response->assertOk();
$response->assertSee('Enrollment notifications');

View File

@ -56,6 +56,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'name' => 'Tenant One',
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -148,6 +149,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
]);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create();
$backupItem = BackupItem::factory()
->for($tenant)
@ -251,6 +253,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'name' => 'Tenant Three',
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -365,6 +368,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'status' => 'active',
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -481,6 +485,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'name' => 'Tenant One',
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
@ -586,6 +591,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'name' => 'Tenant Four',
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',

View File

@ -26,7 +26,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
bindFailHardGraphClient();
$this->actingAs($user)
->get(RestoreRunResource::getUrl('create', tenant: $tenant))
->get(RestoreRunResource::getUrl('create').'?tenant='.(string) $tenant->external_id)
->assertOk()
->assertSee('Create restore run')
->assertSee('Select Backup Set');
@ -54,7 +54,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName
bindFailHardGraphClient();
$url = RestoreRunResource::getUrl('create', tenant: $tenant).'?backup_set_id='.$backupSet->getKey();
$url = RestoreRunResource::getUrl('create').'?backup_set_id='.$backupSet->getKey().'&tenant='.(string) $tenant->external_id;
$this->actingAs($user)
->get($url)

View File

@ -50,10 +50,10 @@
],
]);
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant))
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index').'?tenant='.(string) $tenant->external_id)
->assertSuccessful();
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings')
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id)
->assertSuccessful();
$originalEnv !== false
@ -116,9 +116,9 @@
],
]);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id;
$this->get($url.'?tab=diff')
$this->get($url.'&tab=diff')
->assertSuccessful()
->assertSeeText('Fullscreen')
->assertSeeText("- Write-Host 'one'")
@ -181,9 +181,9 @@
],
]);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id;
$this->get($url.'?tab=diff')
$this->get($url.'&tab=diff')
->assertSuccessful()
->assertSeeText('Fullscreen')
->assertSeeText("- Write-Host 'one'")

View File

@ -73,6 +73,8 @@ public function request(string $method, string $path, array $options = []): Grap
'is_current' => true,
]);
ensureDefaultProviderConnection($tenant);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
@ -126,6 +128,8 @@ public function request(string $method, string $path, array $options = []): Grap
'is_current' => true,
]);
ensureDefaultProviderConnection($tenant);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;

View File

@ -2,7 +2,10 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
@ -82,6 +85,27 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant->makeCurrent();
expect(Tenant::current()->id)->toBe($tenant->id);
$workspace = Workspace::factory()->create();
$tenant->forceFill(['workspace_id' => (int) $workspace->getKey()])->save();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenant->tenant_id,
'is_default' => true,
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
],
]);
app(PolicySyncService::class)->syncPolicies($tenant);
$settingsPolicy = Policy::where('policy_type', 'settingsCatalogPolicy')->first();
@ -113,7 +137,7 @@ public function request(string $method, string $path, array $options = []): Grap
$response = $this
->actingAs($user)
->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant)));
->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant)));
$response->assertOk();
$response->assertSee('Settings Catalog Policy');

View File

@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-3',

View File

@ -3,6 +3,8 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
@ -12,6 +14,30 @@
uses(RefreshDatabase::class);
if (! function_exists('makeTenantWithDefaultProviderConnection')) {
function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
{
$tenant = Tenant::create(array_merge([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
], $attributes));
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->id,
'provider' => 'microsoft',
'is_default' => true,
'status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->id,
]);
return $tenant;
}
}
class SettingsCatalogRestoreGraphClient implements GraphClientInterface
{
/**
@ -105,11 +131,7 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant = makeTenantWithDefaultProviderConnection();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -204,7 +226,7 @@ public function request(string $method, string $path, array $options = []): Grap
->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance');
$response = $this
->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
->get(route('filament.tenant.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
$response->assertOk();
$response->assertSee('settings are read-only');
@ -225,10 +247,9 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
$tenant = makeTenantWithDefaultProviderConnection([
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
]);
$policy = Policy::create([
@ -346,10 +367,9 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
$tenant = makeTenantWithDefaultProviderConnection([
'tenant_id' => 'tenant-4',
'name' => 'Tenant Four',
'metadata' => [],
]);
$policy = Policy::create([
@ -464,10 +484,9 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
$tenant = makeTenantWithDefaultProviderConnection([
'tenant_id' => 'tenant-5',
'name' => 'Tenant Five',
'metadata' => [],
]);
$policy = Policy::create([

View File

@ -56,8 +56,10 @@
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$tenant->makeCurrent();
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings');
->get(PolicyResource::getUrl('view', ['record' => $policy], panel: 'admin').'?tab=settings&tenant='.(string) $tenant->external_id);
$policyResponse->assertOk();
$policyResponse->assertSee('fi-width-full');
@ -68,7 +70,7 @@
$policyResponse->assertSee('fi-ta-table');
$versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
->get(PolicyVersionResource::getUrl('view', ['record' => $version], panel: 'admin').'?tenant='.(string) $tenant->external_id);
$versionResponse->assertOk();
$versionResponse->assertSee('fi-width-full');

View File

@ -22,7 +22,7 @@
$unauthorizedTenant = Tenant::factory()->create();
$this->actingAs($user)
->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant)))
->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant)))
->assertNotFound();
});

View File

@ -2,6 +2,8 @@
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
@ -19,13 +21,30 @@
function tenantWithApp(): Tenant
{
return Tenant::create([
$tenant = Tenant::create([
'tenant_id' => 'tenant-guid',
'name' => 'Tenant One',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret',
'status' => 'active',
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-123',
'client_secret' => 'secret',
],
]);
return $tenant;
}
test('rbac action prompts login when no delegated token', function () {

View File

@ -4,6 +4,8 @@
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
@ -13,6 +15,30 @@
uses(RefreshDatabase::class);
if (! function_exists('makeTenantWithDefaultProviderConnection')) {
function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
{
$tenant = Tenant::create(array_merge([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
], $attributes));
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->id,
'provider' => 'microsoft',
'is_default' => true,
'status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->id,
]);
return $tenant;
}
}
class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface
{
/**
@ -62,11 +88,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$client = new WindowsUpdateProfilesRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant = makeTenantWithDefaultProviderConnection();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -141,11 +163,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$client = new WindowsUpdateProfilesRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant = makeTenantWithDefaultProviderConnection();
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -220,11 +238,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$client = new WindowsUpdateProfilesRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant = makeTenantWithDefaultProviderConnection();
$policy = Policy::create([
'tenant_id' => $tenant->id,

View File

@ -4,6 +4,8 @@
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
@ -13,6 +15,30 @@
uses(RefreshDatabase::class);
if (! function_exists('makeTenantWithDefaultProviderConnection')) {
function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
{
$tenant = Tenant::create(array_merge([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
], $attributes));
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->id,
'provider' => 'microsoft',
'is_default' => true,
'status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->id,
]);
return $tenant;
}
}
test('restore execution applies windows update ring and records audit log', function () {
$client = new class implements GraphClientInterface
{
@ -66,11 +92,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant = makeTenantWithDefaultProviderConnection();
$policy = Policy::create([
'tenant_id' => $tenant->id,

View File

@ -18,7 +18,7 @@
]);
// Zero state
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)->assertOk()->assertSee('No dependencies found');
// Create a missing edge and assert badge appears
@ -71,10 +71,10 @@
'relationship_type' => 'depends_on',
]);
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=outbound';
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound';
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound';
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
});
@ -109,8 +109,8 @@
'metadata' => ['last_known_name' => 'Scoped Target'],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant)
.'?direction=outbound&relationship_type=scoped_by';
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
$this->get($url)
->assertOk()
@ -141,7 +141,7 @@
'metadata' => ['last_known_name' => 'Other Tenant Edge'],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
->assertOk()
->assertDontSee('Other Tenant Edge');
@ -170,7 +170,7 @@
],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
->assertOk()
->assertSee('Group (external): 123456…');
@ -239,7 +239,7 @@
],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
->assertOk()
->assertSee('Scope Tag: Finance (6…)')
@ -286,7 +286,7 @@
],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
->assertOk()
->assertSee('Scope Tag: Finance');
@ -301,6 +301,6 @@
'external_id' => (string) Str::uuid(),
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)->assertRedirect();
});

View File

@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap
'is_current' => true,
]);
ensureDefaultProviderConnection($tenant);
Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'config-1',

View File

@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap
'is_current' => true,
]);
ensureDefaultProviderConnection($tenant);
// Create an ignored policy
$policy = Policy::create([
'tenant_id' => $tenant->id,
@ -100,6 +102,8 @@ public function request(string $method, string $path, array $options = []): Grap
'is_current' => true,
]);
ensureDefaultProviderConnection($tenant);
// Create multiple ignored policies
Policy::create([
'tenant_id' => $tenant->id,

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
@ -12,7 +13,7 @@
[$user] = createUserWithTenant($tenant, role: 'readonly');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/tenants/{$tenant->id}/edit")
->get(TenantResource::getUrl('edit', ['record' => $tenant]))
->assertForbidden();
});

View File

@ -25,7 +25,6 @@
->get('/admin/operations')
->assertOk()
->assertSee($workspaceName ?? 'Select workspace')
->assertSee('Select tenant')
->assertSee('Search tenants…')
->assertSee('Switch workspace')
->assertSee('admin/select-tenant')

View File

@ -38,7 +38,7 @@
expect($failureSummaryJson)->not->toContain($rawBearer);
expect($failureSummaryJson)->not->toContain('test.user@example.com');
expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('permission_denied');
expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('provider_permission_denied');
$notification = DatabaseNotification::query()
->where('notifiable_id', $user->getKey())

View File

@ -19,6 +19,7 @@
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant);
// Simulate an older bug: ESP row was synced under enrollmentRestriction.
$wrong = Policy::create([
@ -81,6 +82,7 @@
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant);
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$payload = [
@ -172,6 +174,7 @@
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant);
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$limitPayload = [

View File

@ -212,8 +212,8 @@
$response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)).'?tab=normalized-settings');
['record' => $version, 'tab' => 'normalized-settings'],
)));
$response->assertOk();
$response->assertSee('Password & Access');

View File

@ -4,10 +4,12 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\ScopeTagResolver;
@ -16,6 +18,7 @@
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RestoreService;
use App\Services\Inventory\InventorySyncService;
use App\Support\OperationRunType;
use App\Support\RbacReason;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -26,11 +29,14 @@
*/
function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
{
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'tenant_id' => $tenantId,
'status' => 'active',
'app_client_id' => null,
'app_client_secret' => null,
'workspace_id' => (int) $workspace->getKey(),
]);
$connection = ProviderConnection::factory()->create([
@ -79,17 +85,23 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
app()->instance(GraphClientInterface::class, $graph);
$run = app(InventorySyncService::class)->syncNow(
$setup['tenant'],
[
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
],
);
$service = app(InventorySyncService::class);
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
expect($run->status)->toBe('success');
$opRun = OperationRun::factory()->create([
'tenant_id' => (int) $setup['tenant']->getKey(),
'workspace_id' => (int) $setup['tenant']->workspace_id,
'type' => OperationRunType::InventorySync->value,
]);
$result = $service->executeSelection($opRun, $setup['tenant'], $selection);
expect($result['status'])->toBe('success');
});
it('Spec081 smoke: policy sync uses provider connection credentials with tenant secrets empty', function (): void {

View File

@ -3,7 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
@ -101,6 +101,14 @@
->test(ManagedTenantOnboardingWizard::class)
->call('startVerification');
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
Queue::assertNothingPushed();
});
});

View File

@ -1,6 +1,7 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use Filament\Facades\Filament;
describe('Register tenant page authorization', function () {
it('is not visible for readonly members', function () {
@ -9,7 +10,11 @@
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setCurrentPanel(Filament::getPanel('tenant'));
expect(RegisterTenant::canView())->toBeFalse();
Filament::setCurrentPanel(null);
});
it('is visible for owner members', function () {
@ -18,6 +23,10 @@
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setCurrentPanel(Filament::getPanel('tenant'));
expect(RegisterTenant::canView())->toBeTrue();
Filament::setCurrentPanel(null);
});
});

View File

@ -2,6 +2,7 @@
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -12,11 +13,15 @@
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
expect(RegisterTenant::canView())->toBeFalse();
Livewire::actingAs($user)
->test(RegisterTenant::class)
->assertStatus(404);
Filament::setCurrentPanel(null);
});
test('readonly users cannot create tenants', function () {

View File

@ -81,6 +81,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-1',
@ -185,6 +187,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-1',
@ -271,6 +275,8 @@ public function request(string $method, string $path, array $options = []): Grap
'metadata' => [],
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-1',

View File

@ -65,6 +65,8 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,

View File

@ -63,6 +63,8 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 2,

View File

@ -82,6 +82,7 @@ public function request(string $method, string $path, array $options = []): Grap
config()->set('graph_contracts.types.securityBaselinePolicy', []);
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,

View File

@ -5,6 +5,7 @@
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -19,6 +20,8 @@
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
$tenantGuid = '11111111-1111-1111-1111-111111111111';
Livewire::test(RegisterTenant::class)
@ -28,6 +31,8 @@
->set('data.domain', 'acme.example')
->call('register');
Filament::setCurrentPanel(null);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$membership = TenantMembership::query()

View File

@ -95,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']);
ensureDefaultProviderConnection($tenant);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'tc-1',
@ -182,6 +183,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('syncs terms and conditions from graph', function () {
$tenant = Tenant::factory()->create(['status' => 'active']);
ensureDefaultProviderConnection($tenant);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')

View File

@ -1,5 +1,7 @@
<?php
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
@ -179,3 +181,41 @@ function filamentTenantRouteParams(Tenant $tenant): array
{
return ['tenant' => (string) $tenant->external_id];
}
function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
{
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', $provider)
->where('is_default', true)
->orderBy('id')
->first();
if (! $connection instanceof ProviderConnection) {
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => $provider,
'entra_tenant_id' => (string) ($tenant->tenant_id ?? fake()->uuid()),
'status' => 'connected',
'health_status' => 'ok',
'is_default' => true,
]);
}
$credential = $connection->credential()->first();
if (! $credential instanceof ProviderCredential) {
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => fake()->uuid(),
'client_secret' => fake()->sha1(),
],
]);
$connection->refresh();
}
return $connection;
}

View File

@ -17,16 +17,16 @@
$patterns = [
// $membership->role === 'owner' / !== 'owner'
'/->role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i',
'/->role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i',
// $role === 'owner'
'/\$role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i',
'/\$role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i',
// case 'owner':
'/\bcase\s*[\"\']?'.$roleValuePattern.'[\"\']?\s*:/i',
'/\bcase\s*(["\'])'.$roleValuePattern.'\1\s*:/i',
// match (...) { 'owner' => ... }
'/\bmatch\b[\s\S]*?\{[\s\S]*?[\"\']?'.$roleValuePattern.'[\"\']?\s*=>/i',
'/\bmatch\b[\s\S]*?\{[\s\S]*?(["\'])'.$roleValuePattern.'\1\s*=>/i',
];
$filesystem = new Filesystem;