diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index abcca39..ce2490d 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -23,16 +23,20 @@ use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Str; use UnitEnum; class ProviderConnectionResource extends Resource @@ -43,18 +47,39 @@ class ProviderConnectionResource extends Resource protected static ?string $model = ProviderConnection::class; - protected static ?string $slug = 'tenants/{tenant}/provider-connections'; + protected static ?string $slug = 'provider-connections'; protected static bool $isGloballySearchable = false; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link'; - protected static string|UnitEnum|null $navigationGroup = 'Providers'; + protected static string|UnitEnum|null $navigationGroup = 'Settings'; - protected static ?string $navigationLabel = 'Connections'; + protected static ?string $navigationLabel = 'Provider Connections'; protected static ?string $recordTitleAttribute = 'display_name'; + public static function getNavigationParentItem(): ?string + { + return 'Integrations'; + } + + public static function canCreate(): bool + { + $tenant = static::resolveTenantForCreate(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE); + } + protected static function hasTenantCapability(string $capability): bool { $tenant = static::resolveScopedTenant(); @@ -73,6 +98,12 @@ protected static function hasTenantCapability(string $capability): bool protected static function resolveScopedTenant(): ?Tenant { + $tenantExternalId = static::resolveRequestedTenantExternalId(); + + if (is_string($tenantExternalId) && $tenantExternalId !== '') { + return static::resolveTenantByExternalId($tenantExternalId); + } + $routeTenant = request()->route('tenant'); if ($routeTenant instanceof Tenant) { @@ -85,19 +116,103 @@ protected static function resolveScopedTenant(): ?Tenant ->first(); } - $externalId = static::resolveTenantExternalIdFromLivewireRequest(); + $recordTenant = static::resolveTenantFromRouteRecord(); - if (is_string($externalId) && $externalId !== '') { - return Tenant::query() - ->where('external_id', $externalId) - ->first(); + if ($recordTenant instanceof Tenant) { + return $recordTenant; } - $filamentTenant = \Filament\Facades\Filament::getTenant(); + $contextTenantExternalId = static::resolveContextTenantExternalId(); + + if (is_string($contextTenantExternalId) && $contextTenantExternalId !== '') { + return static::resolveTenantByExternalId($contextTenantExternalId); + } + + $filamentTenant = Filament::getTenant(); return $filamentTenant instanceof Tenant ? $filamentTenant : null; } + public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant + { + if ($record instanceof ProviderConnection) { + $tenant = $record->tenant; + + if (! $tenant instanceof Tenant && is_numeric($record->tenant_id)) { + $tenant = Tenant::query()->whereKey((int) $record->tenant_id)->first(); + } + + if ($tenant instanceof Tenant) { + return $tenant; + } + } + + return static::resolveScopedTenant(); + } + + public static function resolveRequestedTenantExternalId(): ?string + { + $queryTenant = request()->query('tenant_id'); + + if (is_string($queryTenant) && $queryTenant !== '') { + return $queryTenant; + } + + return static::resolveTenantExternalIdFromLivewireRequest(); + } + + public static function resolveContextTenantExternalId(): ?string + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $contextTenantId = app(WorkspaceContext::class)->lastTenantId(request()); + + if ($workspaceId !== null && $contextTenantId !== null) { + $tenant = Tenant::query() + ->whereKey($contextTenantId) + ->where('workspace_id', (int) $workspaceId) + ->first(); + + if ($tenant instanceof Tenant) { + return (string) $tenant->external_id; + } + } + + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof Tenant) { + return (string) $filamentTenant->external_id; + } + + return null; + } + + public static function resolveTenantForCreate(): ?Tenant + { + $tenantExternalId = static::resolveRequestedTenantExternalId() ?? static::resolveContextTenantExternalId(); + + if (! is_string($tenantExternalId) || $tenantExternalId === '') { + return null; + } + + $tenant = static::resolveTenantByExternalId($tenantExternalId); + $user = auth()->user(); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! $tenant instanceof Tenant || ! $user instanceof User || $workspaceId === null) { + return null; + } + + if ((int) $tenant->workspace_id !== (int) $workspaceId) { + return null; + } + + if (! $user->canAccessTenant($tenant)) { + return null; + } + + return $tenant; + } + private static function resolveTenantExternalIdFromLivewireRequest(): ?string { if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) { @@ -129,6 +244,18 @@ private static function resolveTenantExternalIdFromLivewireRequest(): ?string private static function extractTenantExternalIdFromUrl(string $url): ?string { + $query = parse_url($url, PHP_URL_QUERY); + + if (is_string($query) && $query !== '') { + parse_str($query, $queryParams); + + $tenantExternalId = $queryParams['tenant_id'] ?? null; + + if (is_string($tenantExternalId) && $tenantExternalId !== '') { + return $tenantExternalId; + } + } + $path = parse_url($url, PHP_URL_PATH); if (! is_string($path) || $path === '') { @@ -142,6 +269,119 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string return (string) $matches[1]; } + private static function resolveTenantByExternalId(?string $externalId): ?Tenant + { + if (! is_string($externalId) || $externalId === '') { + return null; + } + + return Tenant::query() + ->where('external_id', $externalId) + ->first(); + } + + private static function resolveTenantFromRouteRecord(): ?Tenant + { + $record = request()->route('record'); + + if ($record instanceof ProviderConnection) { + return static::resolveTenantForRecord($record); + } + + if (! is_numeric($record)) { + return null; + } + + $providerConnection = ProviderConnection::query() + ->with('tenant') + ->whereKey((int) $record) + ->first(); + + if (! $providerConnection instanceof ProviderConnection) { + return null; + } + + return static::resolveTenantForRecord($providerConnection); + } + + private static function applyMembershipScope(Builder $query): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $user = auth()->user(); + + if (! is_int($workspaceId)) { + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof Tenant) { + $workspaceId = (int) $filamentTenant->workspace_id; + } + } + + if (! is_int($workspaceId) || ! $user instanceof User) { + return $query->whereRaw('1 = 0'); + } + + return $query + ->where('provider_connections.workspace_id', $workspaceId) + ->whereExists(function ($membershipScope) use ($user, $workspaceId): void { + $membershipScope + ->selectRaw('1') + ->from('tenants as scoped_tenants') + ->join('tenant_memberships as scoped_memberships', function (JoinClause $join) use ($user): void { + $join->on('scoped_memberships.tenant_id', '=', 'scoped_tenants.id') + ->where('scoped_memberships.user_id', '=', (int) $user->getKey()); + }) + ->whereColumn('scoped_tenants.id', 'provider_connections.tenant_id') + ->where('scoped_tenants.workspace_id', '=', $workspaceId); + }); + } + + /** + * @return array + */ + private static function tenantFilterOptions(): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $user = auth()->user(); + + if (! is_int($workspaceId) || ! $user instanceof User) { + return []; + } + + return Tenant::query() + ->select(['tenants.external_id', 'tenants.name', 'tenants.environment']) + ->join('tenant_memberships as filter_memberships', function (JoinClause $join) use ($user): void { + $join->on('filter_memberships.tenant_id', '=', 'tenants.id') + ->where('filter_memberships.user_id', '=', (int) $user->getKey()); + }) + ->where('tenants.workspace_id', $workspaceId) + ->orderBy('tenants.name') + ->get() + ->mapWithKeys(function (Tenant $tenant): array { + $environment = strtoupper((string) ($tenant->environment ?? '')); + $label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name; + + return [(string) $tenant->external_id => $label]; + }) + ->all(); + } + + private static function sanitizeErrorMessage(?string $value): ?string + { + if (! is_string($value) || trim($value) === '') { + return null; + } + + $normalized = preg_replace('/\s+/', ' ', strip_tags($value)); + $normalized = is_string($normalized) ? trim($normalized) : ''; + + if ($normalized === '') { + return null; + } + + return Str::limit($normalized, 120); + } + public static function form(Schema $schema): Schema { return $schema @@ -176,19 +416,39 @@ public static function table(Table $table): Table { return $table ->modifyQueryUsing(function (Builder $query): Builder { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - $tenantId = static::resolveScopedTenant()?->getKey(); + $tenantExternalId = static::resolveRequestedTenantExternalId(); - if ($workspaceId === null) { - return $query->whereRaw('1 = 0'); + if (! is_string($tenantExternalId) || $tenantExternalId === '') { + return $query; } - return $query - ->where('workspace_id', (int) $workspaceId) - ->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + return $query->whereHas('tenant', function (Builder $tenantQuery) use ($tenantExternalId): void { + $tenantQuery->where('external_id', $tenantExternalId); + }); }) ->defaultSort('display_name') + ->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record])) ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label('Tenant') + ->description(function (ProviderConnection $record): ?string { + $environment = $record->tenant?->environment; + + if (! is_string($environment) || trim($environment) === '') { + return null; + } + + return strtoupper($environment); + }) + ->url(function (ProviderConnection $record): ?string { + $tenant = static::resolveTenantForRecord($record); + + if (! $tenant instanceof Tenant) { + return null; + } + + return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + }), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(), Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(), @@ -208,8 +468,44 @@ public static function table(Table $table): Table ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(), + Tables\Columns\TextColumn::make('last_error_reason_code') + ->label('Last error reason') + ->toggleable(), + Tables\Columns\TextColumn::make('last_error_message') + ->label('Last error message') + ->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state)) + ->toggleable(), ]) ->filters([ + SelectFilter::make('tenant') + ->label('Tenant') + ->default(static::resolveScopedTenant()?->external_id) + ->options(static::tenantFilterOptions()) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->whereHas('tenant', function (Builder $tenantQuery) use ($value): void { + $tenantQuery->where('external_id', $value); + }); + }), + SelectFilter::make('provider') + ->label('Provider') + ->options([ + 'microsoft' => 'Microsoft', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->where('provider_connections.provider', $value); + }), SelectFilter::make('status') ->label('Status') ->options([ @@ -225,7 +521,7 @@ public static function table(Table $table): Table return $query; } - return $query->where('status', $value); + return $query->where('provider_connections.status', $value); }), SelectFilter::make('health_status') ->label('Health') @@ -242,8 +538,11 @@ public static function table(Table $table): Table return $query; } - return $query->where('health_status', $value); + return $query->where('provider_connections.health_status', $value); }), + Filter::make('default_only') + ->label('Default only') + ->query(fn (Builder $query): Builder => $query->where('provider_connections.is_default', true)), ]) ->actions([ Actions\ActionGroup::make([ @@ -260,7 +559,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 = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); if (! $tenant instanceof Tenant) { @@ -358,7 +657,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 = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { @@ -454,7 +753,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 = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { @@ -548,9 +847,10 @@ public static function table(Table $table): Table ->label('Set as default') ->icon('heroicon-o-star') ->color('primary') + ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); if (! $tenant instanceof Tenant) { return; @@ -608,8 +908,8 @@ public static function table(Table $table): Table ->required() ->maxLength(255), ]) - ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void { - $tenant = static::resolveScopedTenant(); + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = static::resolveTenantForRecord($record); if (! $tenant instanceof Tenant) { return; @@ -621,6 +921,29 @@ public static function table(Table $table): Table clientSecret: (string) $data['client_secret'], ); + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'client_id' => (string) $data['client_id'], + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + Notification::make() ->title('Credentials updated') ->success() @@ -635,9 +958,10 @@ public static function table(Table $table): Table ->label('Enable connection') ->icon('heroicon-o-play') ->color('success') + ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); if (! $tenant instanceof Tenant) { return; @@ -711,7 +1035,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 = static::resolveScopedTenant(); + $tenant = static::resolveTenantForRecord($record); if (! $tenant instanceof Tenant) { return; @@ -765,23 +1089,11 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - $tenantId = static::resolveScopedTenant()?->getKey(); + $query = parent::getEloquentQuery() + ->with('tenant'); - $query = parent::getEloquentQuery(); - - if ($workspaceId === null) { - return $query->whereRaw('1 = 0'); - } - - if ($tenantId === null) { - return $query->whereRaw('1 = 0'); - } - - return $query - ->where('workspace_id', (int) $workspaceId) - ->where('tenant_id', $tenantId) - ->latest('id'); + return static::applyMembershipScope($query) + ->latest('provider_connections.id'); } public static function getPages(): array @@ -789,51 +1101,65 @@ public static function getPages(): array return [ 'index' => Pages\ListProviderConnections::route('/'), 'create' => Pages\CreateProviderConnection::route('/create'), + 'view' => Pages\ViewProviderConnection::route('/{record}'), 'edit' => Pages\EditProviderConnection::route('/{record}/edit'), ]; } + private static function normalizeTenantExternalId(mixed $tenant): ?string + { + if ($tenant instanceof Tenant) { + return (string) $tenant->external_id; + } + + if (is_string($tenant) && $tenant !== '') { + return $tenant; + } + + if (is_numeric($tenant)) { + $tenantModel = Tenant::query()->whereKey((int) $tenant)->first(); + + if ($tenantModel instanceof Tenant) { + return (string) $tenantModel->external_id; + } + } + + return null; + } + /** * @param array $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) && blank($parameters['tenant'])) { + $panel ??= 'admin'; + $tenantExternalId = null; + + if (array_key_exists('tenant', $parameters)) { + $tenantExternalId = static::normalizeTenantExternalId($parameters['tenant']); unset($parameters['tenant']); } - if (! array_key_exists('tenant', $parameters)) { - if ($tenant instanceof Tenant) { - $parameters['tenant'] = $tenant->external_id; - } - - $resolvedTenant = static::resolveScopedTenant(); - - if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) { - $parameters['tenant'] = $resolvedTenant->external_id; - } + if ($tenantExternalId === null && $tenant instanceof Tenant) { + $tenantExternalId = (string) $tenant->external_id; + } + if ($tenantExternalId === null) { $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; - } + if ($record instanceof ProviderConnection) { + $tenantExternalId = static::resolveTenantForRecord($record)?->external_id; } } - $panel ??= 'admin'; - - if (array_key_exists('tenant', $parameters)) { - $tenant = null; + if ($tenantExternalId === null) { + $tenantExternalId = static::resolveScopedTenant()?->external_id; } - return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters); + if (! array_key_exists('tenant_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') { + $parameters['tenant_id'] = $tenantExternalId; + } + + return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters); } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php index 9029ab2..b275d02 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -84,18 +84,12 @@ protected function afterCreate(): void private function currentTenant(): ?Tenant { - $tenant = request()->route('tenant'); + $tenant = ProviderConnectionResource::resolveTenantForCreate(); if ($tenant instanceof Tenant) { return $tenant; } - if (is_string($tenant) && $tenant !== '') { - return Tenant::query() - ->where('external_id', $tenant) - ->first(); - } - - return Tenant::current(); + return null; } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index bcf7850..75b17cd 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -38,6 +38,24 @@ public function mount($record): void { parent::mount($record); + $recordTenant = $this->record instanceof ProviderConnection + ? ProviderConnectionResource::resolveTenantForRecord($this->record) + : null; + + if ($recordTenant instanceof Tenant) { + $this->scopedTenantExternalId = (string) $recordTenant->external_id; + + return; + } + + $tenantIdFromQuery = request()->query('tenant_id'); + + if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') { + $this->scopedTenantExternalId = $tenantIdFromQuery; + + return; + } + $tenant = request()->route('tenant'); if ($tenant instanceof Tenant) { @@ -310,7 +328,7 @@ protected function getHeaderActions(): array ->required() ->maxLength(255), ]) - ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void { + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { @@ -323,6 +341,29 @@ protected function getHeaderActions(): array clientSecret: (string) $data['client_secret'], ); + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'client_id' => (string) $data['client_id'], + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + Notification::make() ->title('Credentials updated') ->success() @@ -339,6 +380,7 @@ protected function getHeaderActions(): array ->label('Set as default') ->icon('heroicon-o-star') ->color('primary') + ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant && $record->status !== 'disabled' && ! $record->is_default @@ -619,6 +661,7 @@ protected function getHeaderActions(): array ->label('Enable connection') ->icon('heroicon-o-play') ->color('success') + ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = $this->currentTenant(); @@ -798,6 +841,14 @@ protected function handleRecordUpdate(Model $record, array $data): Model private function currentTenant(): ?Tenant { + if (isset($this->record) && $this->record instanceof ProviderConnection) { + $recordTenant = ProviderConnectionResource::resolveTenantForRecord($this->record); + + if ($recordTenant instanceof Tenant) { + return $recordTenant; + } + } + if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') { return Tenant::query() ->where('external_id', $this->scopedTenantExternalId) @@ -816,6 +867,12 @@ private function currentTenant(): ?Tenant ->first(); } + $tenantFromCreateResolution = ProviderConnectionResource::resolveTenantForCreate(); + + if ($tenantFromCreateResolution instanceof Tenant) { + return $tenantFromCreateResolution; + } + return Tenant::current(); } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index ec91c7f..8a8bbb2 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -24,4 +24,19 @@ protected function getHeaderActions(): array ->apply(), ]; } + + public function getTableEmptyStateHeading(): ?string + { + return 'No provider connections found'; + } + + public function getTableEmptyStateDescription(): ?string + { + return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.'; + } + + public function getTableEmptyStateActions(): array + { + return $this->getHeaderActions(); + } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php new file mode 100644 index 0000000..3762a91 --- /dev/null +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php @@ -0,0 +1,28 @@ +label('Edit') + ->icon('heroicon-o-pencil-square') + ->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record])) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + ]; + } +} diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 578ad70..c40c0f8 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -826,6 +826,11 @@ public static function infolist(Schema $schema): Schema ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)), + Infolists\Components\ViewEntry::make('provider_connection_state') + ->label('Provider connection') + ->state(fn (Tenant $record): array => static::providerConnectionState($record)) + ->view('filament.infolists.entries.provider-connection-state') + ->columnSpanFull(), Infolists\Components\TextEntry::make('created_at')->dateTime(), Infolists\Components\TextEntry::make('updated_at')->dateTime(), Infolists\Components\TextEntry::make('rbac_status') @@ -1193,6 +1198,54 @@ private static function resolveProviderClientIdForConsent(Tenant $tenant): ?stri return $clientId !== '' ? $clientId : null; } + /** + * @return array{ + * state:string, + * cta_url:string, + * display_name:?string, + * provider:?string, + * status:?string, + * health_status:?string, + * last_health_check_at:?string, + * last_error_reason_code:?string + * } + */ + private static function providerConnectionState(Tenant $tenant): array + { + $ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin'); + + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->orderByDesc('is_default') + ->orderBy('id') + ->first(); + + if (! $connection instanceof ProviderConnection) { + return [ + 'state' => 'needs_action', + 'cta_url' => $ctaUrl, + 'display_name' => null, + 'provider' => null, + 'status' => null, + 'health_status' => null, + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + ]; + } + + return [ + 'state' => $connection->is_default ? 'default_configured' : 'configured', + 'cta_url' => $ctaUrl, + 'display_name' => (string) $connection->display_name, + 'provider' => (string) $connection->provider, + 'status' => is_string($connection->status) ? $connection->status : null, + 'health_status' => is_string($connection->health_status) ? $connection->health_status : null, + 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), + 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null, + ]; + } + public static function entraUrl(Tenant $tenant): ?string { if ($tenant->app_client_id) { diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index 70ef1f2..9ca54b8 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -39,7 +39,7 @@ protected function getHeaderActions(): array 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')) + ->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin')) ) ->requireCapability(Capabilities::PROVIDER_VIEW) ->apply(), diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index f0b45b4..1c12060 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -73,6 +73,10 @@ public function handle(Request $request, Closure $next): Response abort(404); } + if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/provider-connections')) { + abort(404); + } + $target = ($hasAnyActiveMembership || $canCreateWorkspace) ? '/admin/choose-workspace' : '/admin/no-access'; diff --git a/app/Policies/ProviderConnectionPolicy.php b/app/Policies/ProviderConnectionPolicy.php index 1d0a52f..e7ea307 100644 --- a/app/Policies/ProviderConnectionPolicy.php +++ b/app/Policies/ProviderConnectionPolicy.php @@ -4,9 +4,12 @@ use App\Models\ProviderConnection; use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; +use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; +use Filament\Facades\Filament; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; use Illuminate\Support\Facades\Gate; @@ -15,34 +18,55 @@ class ProviderConnectionPolicy { use HandlesAuthorization; - public function viewAny(User $user): bool + public function viewAny(User $user): Response|bool { - $workspace = $this->currentWorkspace(); - if (! $workspace instanceof Workspace) { - return false; - } + $workspace = $this->currentWorkspace($user); - $tenant = $this->currentTenant(); - - return $tenant instanceof Tenant - && (int) $tenant->workspace_id === (int) $workspace->getKey() - && Gate::forUser($user)->allows('provider.view', $tenant); - } - - public function view(User $user, ProviderConnection $connection): Response|bool - { - $workspace = $this->currentWorkspace(); if (! $workspace instanceof Workspace) { return Response::denyAsNotFound(); } - $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); + $entitledTenants = Tenant::query() + ->select('tenants.*') + ->join('tenant_memberships as policy_memberships', function ($join) use ($user): void { + $join->on('policy_memberships.tenant_id', '=', 'tenants.id') + ->where('policy_memberships.user_id', '=', (int) $user->getKey()); + }) + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->get(); + + if ($entitledTenants->isEmpty()) { + return true; + } + + foreach ($entitledTenants as $tenant) { + if (Gate::forUser($user)->allows(Capabilities::PROVIDER_VIEW, $tenant)) { + return true; + } + } + + return false; + } + + public function view(User $user, ProviderConnection $connection): Response|bool + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + $tenant = $this->tenantForConnection($connection); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); } - if (! Gate::forUser($user)->allows('provider.view', $tenant)) { + if (! $this->isTenantMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows(Capabilities::PROVIDER_VIEW, $tenant)) { return false; } @@ -57,34 +81,46 @@ public function view(User $user, ProviderConnection $connection): Response|bool return true; } - public function create(User $user): bool + public function create(User $user): Response|bool { - $workspace = $this->currentWorkspace(); - if (! $workspace instanceof Workspace) { - return false; - } + $workspace = $this->currentWorkspace($user); - $tenant = $this->currentTenant(); - - return $tenant instanceof Tenant - && (int) $tenant->workspace_id === (int) $workspace->getKey() - && Gate::forUser($user)->allows('provider.manage', $tenant); - } - - public function update(User $user, ProviderConnection $connection): Response|bool - { - $workspace = $this->currentWorkspace(); if (! $workspace instanceof Workspace) { return Response::denyAsNotFound(); } - $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); + $tenant = $this->resolveCreateTenant($workspace); + + if (! $tenant instanceof Tenant || ! $this->isTenantMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows(Capabilities::PROVIDER_MANAGE, $tenant)) { + return false; + } + + return true; + } + + public function update(User $user, ProviderConnection $connection): Response|bool + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + $tenant = $this->tenantForConnection($connection); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); } - if (! Gate::forUser($user)->allows('provider.view', $tenant)) { + if (! $this->isTenantMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows(Capabilities::PROVIDER_MANAGE, $tenant)) { return false; } @@ -101,18 +137,23 @@ public function update(User $user, ProviderConnection $connection): Response|boo public function delete(User $user, ProviderConnection $connection): Response|bool { - $workspace = $this->currentWorkspace(); + $workspace = $this->currentWorkspace($user); + if (! $workspace instanceof Workspace) { return Response::denyAsNotFound(); } - $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); + $tenant = $this->tenantForConnection($connection); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); } - if (! Gate::forUser($user)->allows('provider.manage', $tenant)) { + if (! $this->isTenantMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows(Capabilities::PROVIDER_MANAGE, $tenant)) { return false; } @@ -124,33 +165,65 @@ public function delete(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - return false; + return true; } - private function currentWorkspace(): ?Workspace + private function currentWorkspace(User $user): ?Workspace { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - return is_int($workspaceId) - ? Workspace::query()->whereKey($workspaceId)->first() - : null; + if (! is_int($workspaceId)) { + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof Tenant) { + $workspaceId = (int) $filamentTenant->workspace_id; + } + } + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return null; + } + + if (! app(WorkspaceContext::class)->isMember($user, $workspace)) { + return null; + } + + return $workspace; } - private function currentTenant(): ?Tenant + private function resolveCreateTenant(Workspace $workspace): ?Tenant { - $tenant = request()->route('tenant'); + $tenantExternalId = request()->query('tenant_id'); - if ($tenant instanceof Tenant) { - return $tenant; + if (! is_string($tenantExternalId) || $tenantExternalId === '') { + $lastTenantId = app(WorkspaceContext::class)->lastTenantId(request()); + + if (is_int($lastTenantId)) { + return Tenant::query() + ->whereKey($lastTenantId) + ->where('workspace_id', (int) $workspace->getKey()) + ->first(); + } + + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof Tenant && (int) $filamentTenant->workspace_id === (int) $workspace->getKey()) { + return $filamentTenant; + } + + return null; } - if (is_string($tenant) && $tenant !== '') { - return Tenant::query() - ->where('external_id', $tenant) - ->first(); - } - - return Tenant::current(); + return Tenant::query() + ->where('external_id', $tenantExternalId) + ->where('workspace_id', (int) $workspace->getKey()) + ->first(); } private function tenantForConnection(ProviderConnection $connection): ?Tenant @@ -165,4 +238,12 @@ private function tenantForConnection(ProviderConnection $connection): ?Tenant return null; } + + private function isTenantMember(User $user, Tenant $tenant): bool + { + return TenantMembership::query() + ->where('user_id', (int) $user->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->exists(); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index d7e07e7..c65918a 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -53,6 +53,12 @@ public function panel(Panel $panel): Panel 'primary' => Color::Amber, ]) ->navigationItems([ + NavigationItem::make('Integrations') + ->url(fn (): string => route('filament.admin.resources.provider-connections.index')) + ->icon('heroicon-o-link') + ->group('Settings') + ->sort(15) + ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), NavigationItem::make('Manage workspaces') ->url(function (): string { return route('filament.admin.resources.workspaces.index'); diff --git a/resources/views/filament/infolists/entries/provider-connection-state.blade.php b/resources/views/filament/infolists/entries/provider-connection-state.blade.php new file mode 100644 index 0000000..616a261 --- /dev/null +++ b/resources/views/filament/infolists/entries/provider-connection-state.blade.php @@ -0,0 +1,60 @@ +@php + $state = $getState(); + $state = is_array($state) ? $state : []; + + $connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action'; + $ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#'; + + $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; + $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; + $status = is_string($state['status'] ?? null) ? (string) $state['status'] : null; + $healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null; + $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; + $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; + + $isMissing = $connectionState === 'needs_action'; +@endphp + +
+
+
+
Provider connection
+ @if ($isMissing) +
Needs action: no default Microsoft provider connection is configured.
+ @else +
{{ $displayName ?? 'Unnamed connection' }}
+ @endif +
+ + + Open Provider Connections + +
+ + @unless ($isMissing) +
+
+
Provider
+
{{ $provider ?? 'n/a' }}
+
+
+
Status
+
{{ $status ?? 'n/a' }}
+
+
+
Health
+
{{ $healthStatus ?? 'n/a' }}
+
+
+
Last check
+
{{ $lastCheck ?? 'n/a' }}
+
+
+ + @if ($lastErrorReason) +
+ Last error reason: {{ $lastErrorReason }} +
+ @endif + @endunless +
diff --git a/routes/web.php b/routes/web.php index 71a2d1e..b5ab76b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,8 @@ use App\Http\Controllers\SelectTenantController; use App\Http\Controllers\SwitchWorkspaceController; use App\Http\Controllers\TenantOnboardingController; +use App\Models\ProviderConnection; +use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Support\Workspaces\WorkspaceContext; @@ -161,6 +163,39 @@ ->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index')) ->name('admin.operations.legacy-tenant-index'); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', +]) + ->prefix('/admin/tenants/{tenant:external_id}/provider-connections') + ->group(function (): void { + Route::get('/', function (Tenant $tenant) { + return redirect()->to('/admin/provider-connections?tenant_id='.$tenant->external_id); + })->name('admin.provider-connections.legacy-index'); + + Route::get('/create', function (Tenant $tenant) { + return redirect()->to('/admin/provider-connections/create?tenant_id='.$tenant->external_id); + })->name('admin.provider-connections.legacy-create'); + + Route::get('/{record}/edit', function (Tenant $tenant, mixed $record) { + $connection = ProviderConnection::query() + ->whereKey((int) $record) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->first(); + + abort_unless($connection instanceof ProviderConnection, 404); + + return redirect()->to('/admin/provider-connections/'.$connection->getKey().'/edit?tenant_id='.$tenant->external_id); + })->name('admin.provider-connections.legacy-edit'); + }); + Route::middleware([ 'web', 'panel:admin', diff --git a/specs/089-provider-connections-tenantless-ui/checklists/requirements.md b/specs/089-provider-connections-tenantless-ui/checklists/requirements.md new file mode 100644 index 0000000..0e98392 --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Provider Connections (Tenantless UI + Tenant Transparency) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-12 +**Feature**: [specs/089-provider-connections-tenantless-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 + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` +- Route examples (e.g., `/admin/provider-connections`) and explicit 404/403 semantics are treated as user-facing contract in this repo (required by the constitution), not as implementation detail. +- The UI Action Matrix section is required by this repository’s constitution for admin UI changes. diff --git a/specs/089-provider-connections-tenantless-ui/contracts/admin-provider-connections.openapi.yaml b/specs/089-provider-connections-tenantless-ui/contracts/admin-provider-connections.openapi.yaml new file mode 100644 index 0000000..5e8f9c7 --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/contracts/admin-provider-connections.openapi.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin — Provider Connections (Tenantless) + version: 0.1.0 + description: | + Minimal contract for canonical tenantless navigation routes. This does not attempt to model Livewire/Filament internals. +servers: + - url: / +paths: + /admin/provider-connections: + get: + summary: List provider connections (tenantless) + description: | + Returns the Provider Connections list UI. Data returned/rendered must be scoped to tenants the actor is a member of. + parameters: + - name: tenant_id + in: query + required: false + description: Tenant external ID (UUID). If present, takes precedence over session tenant context. + schema: + type: string + format: uuid + responses: + '200': + description: OK + '404': + description: Not found (non-workspace member) + '403': + description: Forbidden (workspace+tenant member but missing required capability) + + /admin/tenants/{tenantExternalId}/provider-connections: + get: + summary: Legacy redirect to canonical tenantless list + parameters: + - name: tenantExternalId + in: path + required: true + schema: + type: string + format: uuid + responses: + '302': + description: Redirects to /admin/provider-connections?tenant_id={tenantExternalId} + '404': + description: Not found (non-workspace member or not entitled to the tenant) diff --git a/specs/089-provider-connections-tenantless-ui/data-model.md b/specs/089-provider-connections-tenantless-ui/data-model.md new file mode 100644 index 0000000..98341ba --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/data-model.md @@ -0,0 +1,117 @@ +# Phase 1 Design — Data Model (Provider Connections) + +**Feature**: Provider Connections (tenantless UI + tenant transparency) + +This feature primarily re-frames existing data under a canonical tenantless UI while enforcing workspace/tenant isolation. + +## Entities + +### Workspace + +Represents the top-level isolation boundary. Users must be workspace members to access any Provider Connections surface. + +**Key relationships** +- Workspace has many Tenants. +- Workspace has many ProviderConnections. + +### Tenant + +A managed tenant inside a Workspace. + +**Key relationships** +- Tenant belongs to Workspace (`tenants.workspace_id`). +- Tenant has many ProviderConnections (`provider_connections.tenant_id`). + +### ProviderConnection + +An integration record owned by exactly one Tenant (and by extension, one Workspace). + +**Storage**: `provider_connections` (PostgreSQL) + +**Key columns (existing schema)** +- `workspace_id` (FK-ish, scoped to Workspace) +- `tenant_id` (FK to Tenants) +- `provider` (string; MVP: `microsoft`) +- `entra_tenant_id` (string) +- `display_name` (string) +- `is_default` (bool) +- `status` (string) +- `health_status` (string) +- `scopes_granted` (jsonb) +- `last_health_check_at` (timestamp) +- `last_error_reason_code` (string|null) +- `last_error_message` (string|null; sanitized/truncated) +- `metadata` (jsonb) + +**Constraints / indexes (existing)** +- Unique: `(tenant_id, provider, entra_tenant_id)` +- Partial unique: one default per `(tenant_id, provider)` where `is_default = true` + +**Relationships** +- ProviderConnection belongs to Tenant. +- ProviderConnection belongs to Workspace. + +**State / badges** +- `status`: expected to map through BadgeCatalog (domain: ProviderConnectionStatus) +- `health_status`: expected to map through BadgeCatalog (domain: ProviderConnectionHealth) + +### TenantMembership (isolation boundary) + +A user-to-tenant entitlement table (exact schema is implementation-defined in the app; referenced by existing auth services). + +**Purpose** +- Drives query-time scoping (JOIN-based) for list views. +- Drives deny-as-not-found semantics for direct record access. + +### WorkspaceMembership (isolation boundary) + +A user-to-workspace entitlement table. + +**Purpose** +- Gates the entire feature with 404 semantics for non-members. + +### AuditLog (governance) + +Used for state-changing actions (manage actions). + +**Requirements** +- Stable action IDs. +- Redacted/sanitized payloads (no secrets). + +### OperationRun (observability) + +Used for run/health actions. + +**Requirements** +- Canonical “view run” link. +- Reason codes + sanitized messages. + +## Query Scoping (JOIN-based) + +**Goal**: No metadata leaks and MSP-scale performance. + +List and global data pulls MUST: +- Start from `provider_connections`. +- JOIN to tenants and membership tables to enforce entitlement. +- Optionally apply a tenant filter based on: + 1) `tenant_id` querystring (takes precedence) + 2) otherwise, active TenantContext-derived default + +Direct record access MUST: +- Resolve the owning tenant from the record. +- If user is not entitled to that tenant (or not a workspace member), respond 404. +- If entitled but lacking capability, respond 403. + +## Validation Rules (UI-level) + +- `provider` must be one of allowed providers (MVP: `microsoft`). +- `entra_tenant_id` must be present and formatted as expected (string). +- `display_name` required. +- Actions that toggle `is_default` must preserve the partial unique invariant. +- Any stored error messages must be sanitized and truncated. + +## Data Minimization / Secrets + +- No plaintext secrets are ever rendered. +- No “copy secret” affordances. +- Non-secret identifiers (e.g., Entra tenant ID) may be copyable. diff --git a/specs/089-provider-connections-tenantless-ui/plan.md b/specs/089-provider-connections-tenantless-ui/plan.md new file mode 100644 index 0000000..1dbe006 --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/plan.md @@ -0,0 +1,116 @@ +# Implementation Plan: Provider Connections (Tenantless UI + Tenant Transparency) + +**Branch**: `089-provider-connections-tenantless-ui` | **Date**: 2026-02-12 | **Spec**: `specs/089-provider-connections-tenantless-ui/spec.md` +**Input**: Feature specification from `specs/089-provider-connections-tenantless-ui/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Move Provider Connections to a canonical tenantless admin route (`/admin/provider-connections`) while enforcing workspace/tenant isolation (404 for non-members) and capability-first authorization (403 for members lacking capability). The list/details stay tenant-transparent (tenant column + deep links) and respect an active tenant context for default filtering, with a `tenant_id` querystring override. + +## Technical Context + + + +**Language/Version**: PHP 8.4.15 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, PostgreSQL +**Storage**: PostgreSQL (Sail locally; production via Dokploy) +**Testing**: Pest v4 (+ PHPUnit runner), Livewire component testing for Filament pages/actions +**Target Platform**: Web app (Laravel), admin panel under `/admin` +**Project Type**: Web application (single Laravel monolith) +**Performance Goals**: JOIN-based membership scoping (no large `whereIn` lists), eager-load tenant on list/detail, paginate safely for MSP-scale datasets +**Constraints**: Workspace non-members are 404; tenant non-members are 404; capability denial is 403 after membership is established; no plaintext secrets rendered; global search disabled for Provider Connections +**Scale/Scope**: MSP-style workspaces with many tenants and many provider connections + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- 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) +- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy +- 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 +- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted + +**Gate evaluation (pre-Phase 0)** + +- Inventory-first: Not impacted (UI/routing + entitlement/scoping only). +- Read/write separation: Manage actions are state-changing and will be confirmed + audited; run/health uses OperationRun. +- Graph contract path: Any health check job must use existing Graph abstractions; no external calls at render time. +- Deterministic capabilities: Uses existing capability registry (`PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_RUN`). +- Workspace/Tenant isolation: Enforced via existing membership middleware + policy deny-as-not-found patterns. +- Global search: Explicitly disabled for Provider Connections. +- Action surface contract: List/detail surfaces must keep inspection affordance and proper action grouping. + +## Project Structure + +### Documentation (this feature) + +```text +specs/089-provider-connections-tenantless-ui/ +├── 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/ +│ └── Resources/ +│ └── ProviderConnectionResource.php +│ └── ProviderConnectionResource/ +│ └── Pages/ +│ ├── ListProviderConnections.php +│ ├── CreateProviderConnection.php +│ └── EditProviderConnection.php +├── Policies/ +│ └── ProviderConnectionPolicy.php +├── Support/ +│ ├── Auth/Capabilities.php +│ ├── Rbac/UiEnforcement.php +│ └── Workspaces/WorkspaceContext.php +└── Http/ + └── Middleware/ + ├── EnsureWorkspaceMember.php + └── EnsureWorkspaceSelected.php + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ + ├── Filament/ + └── Policies/ +``` + +**Structure Decision**: Implement as a Laravel/Filament change (resource slug, navigation placement, scoping, and legacy redirects) plus targeted Pest tests. No new standalone services or packages are required. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | + +No constitution violations are required for this feature. diff --git a/specs/089-provider-connections-tenantless-ui/quickstart.md b/specs/089-provider-connections-tenantless-ui/quickstart.md new file mode 100644 index 0000000..10f7f77 --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/quickstart.md @@ -0,0 +1,37 @@ +# Quickstart — Spec 089 (Provider Connections tenantless UI) + +## Prereqs + +- Docker + Docker Compose +- Laravel Sail (project standard) + +## Run locally + +- Start services: `vendor/bin/sail up -d` +- Run migrations (if needed): `vendor/bin/sail artisan migrate` +- Run dev assets (if you’re checking UI): `vendor/bin/sail npm run dev` + +## Key routes + +- Canonical list: `/admin/provider-connections` +- Optional filter: `/admin/provider-connections?tenant_id=` +- Legacy redirect (must remain ≥2 releases): `/admin/tenants//provider-connections` → canonical + +## Run targeted tests + +- Full suite (compact): `vendor/bin/sail artisan test --compact` + +Suggested focused tests (adjust once implementation lands): +- `vendor/bin/sail artisan test --compact tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php` +- `vendor/bin/sail artisan test --compact tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php` + +## Formatting + +- `vendor/bin/sail bin pint --dirty` + +## Notes + +- Non-workspace members must get 404. +- Non-tenant members must get 404 for direct record access. +- Tenant members missing capabilities must get 403. +- Global search must not expose Provider Connections. diff --git a/specs/089-provider-connections-tenantless-ui/research.md b/specs/089-provider-connections-tenantless-ui/research.md new file mode 100644 index 0000000..f755891 --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/research.md @@ -0,0 +1,82 @@ +# Phase 0 Research — Provider Connections (Tenantless UI) + +**Branch**: `089-provider-connections-tenantless-ui` +**Date**: 2026-02-12 + +This document resolves planning unknowns and records decisions with rationale. + +## Findings (Repo Reality) + +- Provider connections already exist as a Filament resource with a tenant-scoped slug. +- The app has a concept of an active workspace context (session-selected) and an active tenant context. +- Workspace membership and tenant membership are treated as hard isolation boundaries (deny-as-not-found 404). +- There are existing patterns for: + - Query scoping by workspace + tenant membership + - UI enforcement (disabled actions + tooltips) with server-side 403 enforcement + - Audit logging (AuditLog) for state changes + - OperationRun for observable run/health operations + - Legacy redirect precedent (tenant-scoped → tenantless) for Operations + +## Decisions + +### D1 — Canonical routing (tenantless) + +**Decision**: Provider Connections will move to a canonical tenantless admin route: `/admin/provider-connections`. + +**Rationale**: Removes fragile tenant-route coupling, matches enterprise IA, and simplifies testing. + +**Alternatives considered**: +- Keep tenant-scoped slug and add more navigation shortcuts. +- Create a separate “workspace integrations” page and keep resource tenant-scoped. + +### D2 — MVP provider scope + +**Decision**: MVP supports Microsoft (Graph) as the only provider; UI/IA remains generic “Provider Connections”. + +**Rationale**: Reduces scope while keeping the data model/provider column future-proof. + +**Alternatives considered**: +- Rename IA to “Microsoft Graph Connections” (more precise but less extensible). +- Support multiple providers in MVP (expensive and not required for current goal). + +### D3 — Tenant context default filtering + +**Decision**: Default tenant filter resolves from active TenantContext (session/context switcher). If `tenant_id` is present in the query string, it takes precedence. + +**Rationale**: Matches existing tenant-context behavior across tenantless pages while enabling deep links (e.g., from tenant detail) without special referrer logic. + +**Alternatives considered**: +- Only querystring (no session context) → worse UX and inconsistent with app patterns. +- Persist per-page last filter only → adds state complexity and surprise. + +### D4 — Zero-leak scoping strategy (JOIN-based) + +**Decision**: ProviderConnections list/query will be scoped using query-time membership joins (workspace + tenant), not large in-memory ID lists. + +**Rationale**: Supports MSP scale and avoids `whereIn([ids])` lists. + +**Alternatives considered**: +- Load entitled tenant IDs in PHP and apply `whereIn` → doesn’t scale. + +### D5 — Global search + +**Decision**: Provider Connections will not appear in Global Search. + +**Rationale**: Global search is a high-leak vector for cross-tenant metadata previews; navigation placement makes search unnecessary. + +**Alternatives considered**: +- Enable global search with strict scoping → still increases risk surface and complexity. + +### D6 — Auditability + +**Decision**: Manage/state-change actions write an AuditLog entry. Run/health actions create an OperationRun with canonical “view run” link. + +**Rationale**: Aligns with constitution: state changes are auditable and operations are observable. + +**Alternatives considered**: +- Only AuditLog → loses operational traceability for runs. +- Only OperationRun → weaker governance for state change events. + +## Open Questions + +None remaining for planning. Implementation will confirm exact join table names and reuse existing scoping helpers. diff --git a/specs/089-provider-connections-tenantless-ui/spec.md b/specs/089-provider-connections-tenantless-ui/spec.md new file mode 100644 index 0000000..b45c0cb --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/spec.md @@ -0,0 +1,182 @@ +# Feature Specification: Provider Connections (Tenantless UI + Tenant Transparency) + +**Feature Branch**: `089-provider-connections-tenantless-ui` +**Created**: 2026-02-12 +**Status**: Draft +**Input**: User description: "Provider Connections als workspace-weites Integrations-Asset (tenantless UI) + Tenant-Transparenz" + +## Clarifications + +### Session 2026-02-12 + +- Q: Welche Provider sind im MVP-Scope dieser Spec? → A: Nur Microsoft (Graph) im MVP; UI/IA bleibt generisch „Provider Connections“. +- Q: Wie wird „aktiver Tenant-Kontext“ für den Default-Filter bestimmt? → A: TenantContext kommt aus Session/Context-Switcher; `tenant_id` Querystring kann den Default überschreiben. +- Q: Soll „Provider Connections“ in Filament Global Search erscheinen? → A: Nein, Global Search ist für diese Resource deaktiviert. +- Q: Wie soll die Auditability konkret umgesetzt werden? → A: AuditLog + OperationRun. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Workspace-weite Übersicht (Priority: P1) + +Als Operator/Admin im Workspace möchte ich Provider Connections zentral unter Settings → Integrations finden und über eine canonical Route ohne Tenant-Parameter aufrufen, damit Integrationen enterprise-typisch auffindbar sind und die UI nicht von tenant-scoped Routes abhängt. + +**Why this priority**: Stellt Informationsarchitektur wieder her, reduziert Kontextbrüche und eliminiert fragiles tenant-scoped Routing als Voraussetzung. + +**Independent Test**: Aufruf der canonical Liste und Verifikation von Scoping + Default Filter + Tenant-Spalte. + +**Acceptance Scenarios**: + +1. **Given** ein User ist Mitglied im Workspace und Mitglied in Tenant A, aber nicht in Tenant B, **When** er die Provider-Connections-Liste öffnet, **Then** sieht er ausschließlich Connections aus Tenant A. +2. **Given** ein aktiver Tenant-Kontext ist gesetzt (Tenant A), **When** die Liste geöffnet wird, **Then** ist initial ein Tenant-A Filter aktiv und kann vom User entfernt werden. +3. **Given** ein aktiver Tenant-Kontext ist gesetzt (Tenant A) und der User ruft die Liste mit `?tenant_id=` auf, **When** die Liste lädt, **Then** ist Tenant B als Filter aktiv (Querystring überschreibt den Context-Default), ohne dass nicht-berechtigte Tenants Metadaten leaken. +4. **Given** ein User ist kein Workspace-Mitglied, **When** er die canonical Provider-Connections-Route aufruft, **Then** erhält er 404 (deny-as-not-found). + +--- + +### User Story 2 - Sicherer Detail-/Edit-Zugriff ohne Secrets (Priority: P2) + +Als Tenant-Mitglied möchte ich eine Provider Connection ansehen und (mit entsprechender Berechtigung) verwalten, damit ich Integrationsprobleme diagnostizieren und beheben kann, ohne dass Secrets im Klartext sichtbar werden. + +**Why this priority**: Detail-/Edit-Surfaces sind die riskantesten Stellen für Metadaten-Leaks und Secret-Exfiltration. + +**Independent Test**: Direkte View/Edit Zugriffe mit unterschiedlichen Membership-/Capability-Kombinationen; UI zeigt keine Klartext-Secrets. + +**Acceptance Scenarios**: + +1. **Given** ein User ist Workspace-Mitglied aber nicht Mitglied im owning Tenant einer Connection, **When** er die Detailseite direkt aufruft, **Then** erhält er 404. +2. **Given** ein User ist Tenant-Mitglied, aber ihm fehlt die View-Capability, **When** er Liste oder Detailseite aufruft, **Then** erhält er 403. +3. **Given** ein User hat View-, aber nicht Manage-Capability, **When** er die Detailseite öffnet, **Then** sind Manage-Aktionen sichtbar aber deaktiviert (mit Tooltip), und ein server-seitiger Mutationsversuch wird mit 403 abgewiesen. +4. **Given** irgendein berechtigter User sieht Liste/Detail, **When** Credentials/Secrets dargestellt werden, **Then** werden niemals Klartext-Secrets angezeigt und es gibt keine Copy-Aktionen für Secrets. + +--- + +### User Story 3 - Tenant-Detailseite zeigt effektiven Provider-State + Deep Link (Priority: P3) + +Als Operator im Tenant-Kontext möchte ich auf der Tenant-Detailseite den effektiven Default-Provider-Connection-Status sehen und über eine CTA direkt zur vorgefilterten Provider-Connections-Liste springen, damit ich Kontext behalte und trotzdem zentral arbeiten kann. + +**Why this priority**: Reduziert Kontextbruch und macht den „effective state“ dort sichtbar, wo Operators ihn erwarten. + +**Independent Test**: Tenant-Detailseite zeigt Card + CTA; CTA führt zur gefilterten canonical Liste. + +**Acceptance Scenarios**: + +1. **Given** Tenant A hat eine aufgelöste Default-Connection, **When** der User die Tenant-Detailseite öffnet, **Then** sieht er Display Name + Status/Health + Last Check. +2. **Given** Tenant A hat keine gültige Default-Connection, **When** der User die Tenant-Detailseite öffnet, **Then** sieht er einen klaren „Needs action“-State und eine CTA zur gefilterten Liste. + +### Edge Cases + +- User ist Workspace-Mitglied, aber in keinem Tenant Mitglied → Liste zeigt 0 Rows (ohne Tenant-Hinweise). +- `tenant_id` Filter verweist auf einen nicht-mitgliedschaftlich berechtigten Tenant → Liste zeigt 0 Rows; direkte Record-Zugriffe bleiben 404. +- Mitgliedschaft wird entzogen → direkte Zugriffe auf vormals sichtbare Records liefern 404. +- Sehr viele Tenants/Connections (MSP) → Scoping bleibt performant (server-seitige, membership-basierte Filterung ohne große In-Memory-ID-Listen). +- Health/Last Error enthält sensitive Inhalte → UI zeigt nur Reason Codes und gekürzte, nicht-sensitive Messages; keine Secrets. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Canonical route)**: The system MUST expose Provider Connections at a canonical admin route without requiring a tenant route parameter (e.g., `/admin/provider-connections`). +- **FR-002 (Tenant filter)**: The canonical list MUST support optional filtering by owning tenant (e.g., `?tenant_id=`). +- **FR-003 (Tenant transparency)**: List and detail MUST clearly show the owning tenant (name + environment indicator where applicable) and provide a deep link to tenant detail. +- **FR-004 (Context-respecting default filter)**: If a tenant context is active, the list MUST default to filtering by the current tenant. The user MUST be able to remove the filter. +- **FR-004a (Default resolution precedence)**: The default tenant filter MUST be resolved from the active TenantContext (session/context switcher). If a `tenant_id` query parameter is present, it MUST take precedence over the TenantContext-derived default. +- **FR-005 (Zero-leak scoping)**: Index/List MUST only return connections for tenants where the user is a member. The UI MUST not reveal metadata for non-member tenants. +- **FR-006 (Direct access semantics)**: Direct record access (view/edit) MUST be deny-as-not-found (404) when the user is not a member of the owning tenant. +- **FR-007 (Workspace gating)**: If the user is not a workspace member, all Provider Connections routes MUST return 404. +- **FR-008 (Capability-first RBAC)**: Capabilities MUST gate: view (list/detail), manage (create/edit/enable/disable/set-default/credential updates), run (health checks / operation triggers). Missing capability MUST return 403. +- **FR-009 (UI enforcement behavior)**: When missing capability but otherwise eligible (member), the UI SHOULD render actions disabled with a tooltip describing the missing capability (not silently removed), while server-side enforcement stays authoritative. +- **FR-010 (No secrets in UI)**: The UI MUST never display plaintext secrets and MUST NOT provide copy actions for secrets. Non-secret identifiers (e.g., Entra tenant ID) MAY be copyable. +- **FR-011 (List columns minimum)**: The list MUST include at least: Tenant, Provider, Display name, Entra tenant ID, Default indicator, Status badge, Health badge, Last check timestamp, Last error (reason code + truncated message). +- **FR-012 (List filters minimum)**: The list MUST provide filters for: Tenant, Provider, Status, Health, Default-only. +- **FR-013 (Tenant detail card)**: Tenant detail MUST show an “effective provider connection state” for the tenant and provide a CTA to open the canonical Provider Connections list pre-filtered for that tenant. +- **FR-014 (Legacy redirect)**: Legacy tenant-scoped URLs for provider connections MUST behave according to the “Legacy URL Redirect Matrix” (302 redirects only for entitled members; no-leak 404 otherwise) and remain for at least two releases. +- **FR-015 (Scalability requirement)**: Tenant-visibility scoping MUST be performed at query time based on membership relationships and MUST NOT depend on loading large tenant-id lists into memory. +- **FR-016 (MVP provider scope)**: The MVP MUST support Microsoft (Graph) as the only provider. The navigation label and IA remain generic (“Provider Connections”). +- **FR-017 (Global Search)**: Provider Connections MUST NOT appear in global search. + +- **FR-018 (Create tenant resolution)**: Create MUST resolve the target tenant to an entitled tenant via `tenant_id` query parameter or the active TenantContext default. If no entitled tenant can be resolved, the create surface MUST behave as not found (404). + +### Information Architecture + +- Provider Connections MUST be placed under Settings → Integrations → Provider Connections. +- Provider Connections MUST feel “workspace-level” even when a tenant context is active (context affects default filtering, not canonical addressability). + +### Scope Boundaries + +- In scope: tenantless canonical navigation and routing; tenant transparency in list/detail; context-respecting default filter; deny-as-not-found membership rules; capability-first action gating; legacy redirect behavior. +- Out of scope: shared connections across multiple tenants; redesign of default/override semantics; changing the meaning of “default”; large wizard refactors. + +### Assumptions & Dependencies + +- A "workspace" membership model exists and can be evaluated for every request. +- A "tenant membership" model exists and is the source of truth for which tenants a user may access. +- Provider Connections already belong to exactly one owning tenant and one workspace. +- A tenant detail surface exists where the “Provider connection” card can be shown. +- The system has, or can represent, a provider identifier for connections (even if only Microsoft exists in the MVP). +- The system has an active TenantContext concept (e.g., chosen via a context switcher) that can be read when rendering admin pages. + +### Query Parameter Contract + +- **QP-001**: The canonical tenant filter query parameter name is `tenant_id`. +- **QP-002**: The `tenant_id` value is the managed tenant’s external identifier (the same identifier used in `/admin/tenants/{tenant}` routes), not a database primary key. + +### Tenant Transparency Conventions + +- **TT-001**: “Environment indicator” means a human-readable label that distinguishes tenant environments when such labeling exists in the workspace (e.g., Production vs Staging). If no such label exists for a tenant, the UI MUST omit the environment indicator (not substitute guesses). + +### Default Filter Removal + +- **DF-001**: The default tenant filter is a usability default only. Users MAY remove it at any time. +- **DF-002**: Removing the default filter MUST NOT change authorization boundaries: list and detail remain scoped to tenants the user is a member of. + +### Authorization Semantics (404 vs 403) + +- **AS-001**: Not a workspace member → 404 for list/detail/edit and any action endpoints. +- **AS-002**: Workspace member but not a member of the owning tenant → list returns no rows for that tenant; direct record access → 404. +- **AS-003**: Tenant member but missing required capability → 403 for the protected surface/action. + +### Audit & Observability + +- **AO-001**: User-initiated actions that change state (set default, enable/disable, credentials update/rotate) MUST be auditable. +- **AO-002**: User-initiated run/health actions MUST be auditable, either via an audit event or a run record that is reachable via a canonical “view run” link. +- **AO-003 (Decision)**: Manage/state-change actions MUST write an AuditLog entry. Run/health actions MUST create an OperationRun (or equivalent run record) that is reachable via a canonical “view run” link. + +### Backward Compatibility + +- **BC-001**: Redirect MUST preserve intent (tenant filter) and MUST NOT leak tenant names/metadata for non-members. +- **BC-002**: Deprecation window is at least two releases. + +### Legacy URL Redirect Matrix + +- **LR-001 (Redirect, tenant-managed scope)**: Requests to legacy workspace-managed tenant routes MUST redirect (302) to the canonical route and preserve intent via the tenant filter: + - `/admin/tenants/{tenant_external_id}/provider-connections` → `/admin/provider-connections?tenant_id={tenant_external_id}` + - `/admin/tenants/{tenant_external_id}/provider-connections/create` → `/admin/provider-connections/create?tenant_id={tenant_external_id}` + - `/admin/tenants/{tenant_external_id}/provider-connections/{record}/edit` → `/admin/provider-connections/{record}/edit?tenant_id={tenant_external_id}` +- **LR-002 (No-leak)**: For non-workspace members or users who are not members of the target tenant, legacy URLs MUST behave as not found (404) and MUST NOT redirect. +- **LR-003 (Explicit exclusion)**: The previously removed tenant-panel management route shape `/admin/t/{tenant_external_id}/provider-connections` remains not found (404) and is not part of the redirect surface. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider Connections (List) | Admin → Settings → Integrations → Provider Connections | Create (manage) | Tenant deep link + View action | View (view), Edit (manage) | None (not required) | Create (manage) | n/a | n/a | Yes | Default tenant filter applies when tenant context active | +| Provider Connection (View) | Provider Connections → View | Enable/Disable (manage), Set default (manage), Health check (run), Credential update (manage) | n/a | Edit (manage) | None | n/a | Same as header actions | n/a | Yes | Non-membership is 404; missing capability is 403; destructive-like actions require confirmation | +| Provider Connection (Create/Edit) | Provider Connections → Create/Edit | None | n/a | None | None | n/a | n/a | Save (manage), Cancel | Yes | Secrets never displayed; credential updates require confirmation | +| Tenant detail “Provider connection” card | Tenant detail page | Open Provider Connections (view) | n/a | None | None | Optional: Create connection (manage) | n/a | n/a | No | CTA links to canonical list filtered by tenant | + +### Key Entities *(include if feature involves data)* + +- **Workspace**: A membership-gated scope that owns managed tenants and integrations. +- **Managed Tenant**: A tenant within a workspace; users are members of specific tenants. +- **Provider Connection**: An integration record owned by exactly one managed tenant and one workspace; includes status/health metadata and non-secret identifiers. +- **Capabilities**: Named permissions that gate view/manage/run behavior; UI enforcement reflects missing capabilities. +- **Audit Event / Run Record**: Captures sensitive user-initiated actions for later review. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (IA discoverability)**: A workspace admin can reach Provider Connections from the admin sidebar in ≤ 2 clicks (Settings → Integrations → Provider Connections). +- **SC-002 (No metadata leaks)**: For non-members of a tenant, 100% of direct access attempts to that tenant’s Provider Connection detail/edit routes return 404 and do not reveal tenant/provider/status/health metadata. +- **SC-003 (RBAC correctness)**: For tenant members lacking a required capability, 100% of protected endpoints return 403; the UI consistently shows disabled actions with an explanatory tooltip. +- **SC-004 (Context-respecting UX)**: From a tenant detail page, the “Open Provider Connections” CTA lands on a pre-filtered list for that tenant with a first-attempt success rate ≥ 95% in acceptance testing. diff --git a/specs/089-provider-connections-tenantless-ui/tasks.md b/specs/089-provider-connections-tenantless-ui/tasks.md new file mode 100644 index 0000000..7e1ae9f --- /dev/null +++ b/specs/089-provider-connections-tenantless-ui/tasks.md @@ -0,0 +1,161 @@ +# Tasks: Provider Connections (Tenantless UI + Tenant Transparency) + +**Input**: Design documents from `specs/089-provider-connections-tenantless-ui/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ + +**Tests**: Required (Pest) — this feature changes runtime auth + routing. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 Confirm spec artifacts present in specs/089-provider-connections-tenantless-ui/{spec,plan,research,data-model,quickstart,tasks}.md +- [X] T002 [P] Validate existing provider capabilities exist in app/Support/Auth/Capabilities.php (PROVIDER_VIEW/PROVIDER_MANAGE/PROVIDER_RUN) +- [X] T003 [P] Inventory existing Provider Connections routes/actions and update the UI Action Matrix in specs/089-provider-connections-tenantless-ui/spec.md (anchor to app/Filament/Resources/ProviderConnectionResource.php) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [X] T004 Define tenant-context default filter precedence contract for Provider Connections (query `tenant_id` overrides session TenantContext) in app/Filament/Resources/ProviderConnectionResource.php +- [X] T005 Implement JOIN-based tenant-membership scoping query helper for Provider Connections in app/Filament/Resources/ProviderConnectionResource.php (no large in-memory tenant-id lists) +- [X] T006 Ensure non-workspace member access is deny-as-not-found (404) for Provider Connections surfaces via existing middleware/policy wiring (verify and adjust in app/Policies/ProviderConnectionPolicy.php and panel middleware if needed) +- [X] T007 Create baseline authorization regression tests for 404 vs 403 semantics in tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php + +**Checkpoint**: Foundation ready (tenant resolution, scoping approach, baseline auth tests). + +--- + +## Phase 3: User Story 1 — Workspace-weite Übersicht (Priority: P1) 🎯 MVP + +**Goal**: Canonical tenantless route + central navigation placement + tenant transparency on list with safe scoping and default filtering. + +**Independent Test**: Workspace member sees only entitled tenant rows at `/admin/provider-connections`, default filter respects TenantContext, `?tenant_id=` overrides, non-workspace member gets 404. + +### Tests (write first) + +- [X] T008 [P] [US1] Add feature test for canonical list route 404 for non-workspace member in tests/Feature/ProviderConnections/TenantlessListRouteTest.php +- [X] T009 [P] [US1] Add feature test for scoping: member of Tenant A not Tenant B sees only A rows in tests/Feature/ProviderConnections/TenantlessListScopingTest.php +- [X] T010 [P] [US1] Add feature test for `tenant_id` query override (authorized tenant shows rows; unauthorized tenant shows 0 rows) in tests/Feature/ProviderConnections/TenantFilterOverrideTest.php + +### Implementation + +- [X] T011 [US1] Change canonical Filament resource slug to tenantless in app/Filament/Resources/ProviderConnectionResource.php (from `tenants/{tenant}/provider-connections` to `provider-connections`) +- [X] T012 [US1] Update Provider Connections navigation placement in app/Filament/Resources/ProviderConnectionResource.php (group: `Settings`, subgroup: `Integrations`, label: `Provider Connections`) +- [X] T013 [US1] Update ProviderConnectionResource::getUrl behavior in app/Filament/Resources/ProviderConnectionResource.php to stop inferring `{tenant}` path params and instead support tenant filter via query string +- [X] T014 [US1] Update ProviderConnectionResource::getEloquentQuery() in app/Filament/Resources/ProviderConnectionResource.php to allow tenantless list + record access while enforcing workspace + tenant membership scoping at query time +- [X] T015 [US1] Update list query scoping in app/Filament/Resources/ProviderConnectionResource.php to: workspace-scope + membership join + optional tenant filter (`tenant_id` query > TenantContext default) +- [X] T016 [US1] Add required tenant transparency columns + filters in app/Filament/Resources/ProviderConnectionResource.php (Tenant column with deep link to Tenant view + Tenant filter) +- [X] T017 [US1] Add required list columns for last error reason/message in app/Filament/Resources/ProviderConnectionResource.php (reason code + truncated message, sanitized) +- [X] T018 [US1] Ensure list has a clear inspection affordance and action-surface contract compliance in app/Filament/Resources/ProviderConnectionResource.php (prefer `recordUrl()` clickable rows; keep max 2 visible row actions) +- [X] T019 [US1] Add meaningful empty state + CTA behavior to app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php + +### Legacy redirect + +- [X] T020 [US1] Add legacy redirects for workspace-managed tenant routes in routes/web.php (302 only for entitled members; otherwise 404; no Location leaks): `/admin/tenants/{tenant:external_id}/provider-connections` → `/admin/provider-connections?tenant_id={tenant_external_id}`, `/admin/tenants/{tenant:external_id}/provider-connections/create` → `/admin/provider-connections/create?tenant_id={tenant_external_id}`, `/admin/tenants/{tenant:external_id}/provider-connections/{record}/edit` → `/admin/provider-connections/{record}/edit?tenant_id={tenant_external_id}` +- [X] T021 [P] [US1] Add legacy redirect tests in tests/Feature/ProviderConnections/LegacyRedirectTest.php (302 for entitled; 404 for non-workspace/non-tenant members; assert no Location header for 404 cases; assert `/admin/t/{tenant_external_id}/provider-connections` remains 404 and does not redirect) + +**Checkpoint**: US1 delivers canonical tenantless list + safe scoping + redirect. + +--- + +## Phase 4: User Story 2 — Sicherer Detail-/Edit-Zugriff ohne Secrets (Priority: P2) + +**Goal**: Secure view/edit + actions gated by capability; non-member 404; member missing capability 403; no plaintext secrets. + +**Independent Test**: Direct record access behaves as spec (404/403), manage actions are confirmed + audited, run actions create OperationRun, secrets never displayed. + +### Tests (write first) + +- [X] T022 [P] [US2] Add feature test: non-tenant member direct record access is 404 in tests/Feature/ProviderConnections/RecordAccessNotFoundTest.php +- [X] T023 [P] [US2] Add feature test: tenant member missing Capabilities::PROVIDER_VIEW gets 403 for list/detail in tests/Feature/ProviderConnections/CapabilityForbiddenTest.php (no raw capability strings) +- [X] T024 [P] [US2] Add feature test: tenant member missing Capabilities::PROVIDER_MANAGE cannot mutate (403) in tests/Feature/ProviderConnections/ManageCapabilityEnforcementTest.php (no raw capability strings) + +### Implementation + +- [X] T025 [US2] Add a Provider Connection View page (required for “detail” semantics) in app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php and register it in app/Filament/Resources/ProviderConnectionResource.php getPages() +- [X] T026 [US2] Update record routes to tenantless paths in app/Filament/Resources/ProviderConnectionResource.php and related Pages/* so record URLs no longer depend on `{tenant}` +- [X] T027 [US2] Update authorization semantics (404 for non-members; 403 for missing capability; viewAny/view=Capabilities::PROVIDER_VIEW; create/update/delete=Capabilities::PROVIDER_MANAGE) in app/Policies/ProviderConnectionPolicy.php +- [X] T028 [US2] Update Edit page tenant resolution to prefer record’s tenant (not route tenant) in app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +- [X] T029 [US2] Update Create page to require explicit tenant selection (query `tenant_id` or context default) in app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php (abort 404 if no entitled tenant can be resolved) +- [X] T030 [US2] Ensure all destructive-like/manage actions require confirmation in app/Filament/Resources/ProviderConnectionResource.php and app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php (enable/disable, set default, credential updates) +- [X] T031 [US2] Ensure manage actions write AuditLog using the correct logger (tenant/workspace as appropriate) and do not include secrets in context (review and adjust in app/Filament/Resources/ProviderConnectionResource.php and Pages/EditProviderConnection.php) +- [X] T032 [US2] Ensure run/health actions create/reuse OperationRun and link to canonical viewer (verify OperationRunLinks tenantless path usage in app/Filament/Resources/ProviderConnectionResource.php and Pages/EditProviderConnection.php) +- [X] T033 [US2] Ensure UI never renders plaintext secrets and provides no secret copy affordances (confirm forms/columns in app/Filament/Resources/ProviderConnectionResource.php) + +**Checkpoint**: US2 secures detail/edit surfaces + action gating + confirmations. + +--- + +## Phase 5: User Story 3 — Tenant-Detailseite zeigt effektiven Provider-State + Deep Link (Priority: P3) + +**Goal**: Tenant view shows effective default provider connection state and links to tenant-filtered canonical list. + +**Independent Test**: Tenant view displays default connection summary and CTA to `/admin/provider-connections?tenant_id=`. + +### Tests (write first) + +- [X] T034 [P] [US3] Add feature test asserting tenant view contains CTA URL to canonical provider connections with tenant_id in tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php + +### Implementation + +- [X] T035 [US3] Update tenant header action URL to tenantless canonical list in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (use `/admin/provider-connections?tenant_id={external_id}` semantics) +- [X] T036 [US3] Add “Provider connection” effective state section to the tenant infolist in app/Filament/Resources/TenantResource.php (display name, status/health, last check; show needs-action state if missing) +- [X] T037 [US3] Add a dedicated infolist entry view for the provider connection state in resources/views/filament/infolists/entries/provider-connection-state.blade.php + +**Checkpoint**: US3 completes tenant transparency from tenant detail. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T038 [P] Run targeted test suite for Provider Connections changes: `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections` (add notes to specs/089-provider-connections-tenantless-ui/quickstart.md if paths differ) +- [X] T039 [P] Run formatting and fix any findings in app/** and tests/** using `vendor/bin/sail bin pint --dirty` +- [X] T040 Review navigation grouping consistency for Settings → Integrations in app/Providers/Filament/AdminPanelProvider.php (ensure Provider Connections appears where spec requires) +- [X] T041 Validate that Provider Connections remains excluded from global search in app/Filament/Resources/ProviderConnectionResource.php + +- [X] T042 [P] Add regression test asserting disabled actions render with helper text/tooltip for tenant members missing capability (UI enforcement) in tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php (or add an explicit exemption in specs/089-provider-connections-tenantless-ui/spec.md if not feasible) +- [X] T043 [P] Add regression test asserting required list filters exist and behave (Provider, Status, Health, Default-only) in tests/Feature/ProviderConnections/RequiredFiltersTest.php +- [X] T044 [P] Add regression test asserting MVP provider scope remains Microsoft-only (no non-Microsoft provider options exposed) in tests/Feature/ProviderConnections/MvpProviderScopeTest.php + +--- + +## Dependencies & Execution Order + + - Setup (T001–T003) → Foundational (T004–T007) → US1 (T008–T021) → US2 (T022–T033) → US3 (T034–T037) → Polish (T038–T044) + +```mermaid +graph TD + P1[Phase 1: Setup] --> P2[Phase 2: Foundational] + P2 --> US1[US1: Tenantless List] + P2 --> US2[US2: Secure View/Edit] + P2 --> US3[US3: Tenant View State] + US1 --> Polish[Phase 6: Polish] + US2 --> Polish + US3 --> Polish +``` + +## Parallel Execution Examples + +### US1 + +- Run in parallel: + - T008, T009, T010 (tests) + - T020 + T021 (redirect + test) can be done alongside T011–T019 once slug decision is finalized + +### US2 + +- Run in parallel: + - T022–T024 (tests) + - T025 (View page scaffolding) and T027 (policy fixes) can be developed independently + +### US3 + +- Run in parallel: + - T034 (test) and T037 (Blade entry view) while T036 changes TenantResource infolist + +## Implementation Strategy + +- MVP = US1 only (canonical tenantless list + safe scoping + legacy redirect). +- Add US2 next (secure detail/edit + action confirmations + audit/run correctness). +- Add US3 last (tenant view effective-state + CTA). diff --git a/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php b/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php index 8b8fce9..2df51b6 100644 --- a/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php +++ b/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php @@ -1,8 +1,8 @@ create(); $otherTenant = Tenant::factory()->create(); [$user] = createUserWithTenant($otherTenant, role: 'owner'); + ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Unauthorized Tenant Connection', + ]); + $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) - ->assertStatus(404); + ->assertOk() + ->assertDontSee('Unauthorized Tenant Connection'); }); test('members without capability see provider connection actions disabled with standard tooltip', function () { @@ -47,10 +54,10 @@ ->assertTableActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $connection); Livewire::actingAs($user) - ->test(EditProviderConnection::class, ['record' => $connection->getKey()]) - ->assertActionVisible('check_connection') - ->assertActionDisabled('check_connection') - ->assertActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + ->test(ViewProviderConnection::class, ['record' => $connection->getKey()]) + ->assertActionVisible('edit') + ->assertActionDisabled('edit') + ->assertActionExists('edit', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); }); test('members with capability can see provider connection actions enabled', function () { @@ -71,7 +78,7 @@ ->assertTableActionEnabled('check_connection', $connection); Livewire::actingAs($user) - ->test(EditProviderConnection::class, ['record' => $connection->getKey()]) - ->assertActionVisible('check_connection') - ->assertActionEnabled('check_connection'); + ->test(ViewProviderConnection::class, ['record' => $connection->getKey()]) + ->assertActionVisible('edit') + ->assertActionEnabled('edit'); }); diff --git a/tests/Feature/Filament/TenantScopingTest.php b/tests/Feature/Filament/TenantScopingTest.php index 2804b0c..8583003 100644 --- a/tests/Feature/Filament/TenantScopingTest.php +++ b/tests/Feature/Filament/TenantScopingTest.php @@ -5,17 +5,30 @@ use App\Filament\Resources\ProviderConnectionResource; use App\Models\ProviderConnection; use App\Models\Tenant; -use Filament\Facades\Filament; -it('returns 404 for non-members on tenant-scoped routes', function (): void { +it('returns an empty canonical list for unauthorized tenant filters', function (): void { $tenantA = Tenant::factory()->create(['name' => 'Tenant A']); $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); [$user] = createUserWithTenant($tenantA, role: 'owner'); + ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'display_name' => 'Tenant A Connection', + ]); + + ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'workspace_id' => (int) $tenantB->workspace_id, + 'display_name' => 'Tenant B Connection', + ]); + $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('index', tenant: $tenantB)) - ->assertNotFound(); + ->assertOk() + ->assertDontSee('Tenant A Connection') + ->assertDontSee('Tenant B Connection'); }); it('does not show non-member tenants in the choose-tenant list', function (): void { @@ -31,40 +44,9 @@ ->assertDontSee('Tenant B'); }); -it('scopes global search results to the current tenant and denies non-members', function (): void { - $tenantA = Tenant::factory()->create(['name' => 'Tenant A']); - $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); +it('keeps provider connections excluded from global search', function (): void { + $property = new ReflectionProperty(ProviderConnectionResource::class, 'isGloballySearchable'); + $property->setAccessible(true); - [$user] = createUserWithTenant($tenantA, role: 'owner'); - - ProviderConnection::factory()->create([ - 'tenant_id' => $tenantA->getKey(), - 'display_name' => 'Acme Connection A', - ]); - - ProviderConnection::factory()->create([ - 'tenant_id' => $tenantB->getKey(), - 'display_name' => 'Acme Connection B', - ]); - - $this->actingAs($user); - - Filament::setTenant($tenantA, true); - - $resultsA = ProviderConnectionResource::getGlobalSearchResults('Acme'); - - expect($resultsA)->toHaveCount(1); - expect((string) $resultsA->first()?->title)->toBe('Acme Connection A'); - - Filament::setTenant($tenantB, true); - - $resultsB = ProviderConnectionResource::getGlobalSearchResults('Acme'); - - expect($resultsB)->toHaveCount(0); - - Filament::setTenant(null, true); - - $resultsNone = ProviderConnectionResource::getGlobalSearchResults('Acme'); - - expect($resultsNone)->toHaveCount(0); + expect($property->getValue())->toBeFalse(); }); diff --git a/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php new file mode 100644 index 0000000..9e4498f --- /dev/null +++ b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php @@ -0,0 +1,29 @@ +create(); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + ]); + + $outsider = User::factory()->create(); + + $this->actingAs($outsider) + ->get('/admin/provider-connections/'.$connection->getKey().'/edit') + ->assertNotFound(); + + [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $this->actingAs($readonly) + ->get('/admin/provider-connections/create?tenant_id='.(string) $tenant->external_id) + ->assertForbidden(); +}); diff --git a/tests/Feature/ProviderConnections/CapabilityForbiddenTest.php b/tests/Feature/ProviderConnections/CapabilityForbiddenTest.php new file mode 100644 index 0000000..fe36b3a --- /dev/null +++ b/tests/Feature/ProviderConnections/CapabilityForbiddenTest.php @@ -0,0 +1,27 @@ + false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections') + ->assertForbidden(); + + $this->actingAs($user) + ->get('/admin/provider-connections/'.$connection->getKey()) + ->assertForbidden(); +}); diff --git a/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php b/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php new file mode 100644 index 0000000..002baa1 --- /dev/null +++ b/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php @@ -0,0 +1,32 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'status' => 'disabled', + 'provider' => 'microsoft', + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()]) + ->assertActionVisible('edit') + ->assertActionDisabled('edit') + ->assertActionExists('edit', function (Action $action): bool { + return $action->getTooltip() === UiTooltips::insufficientPermission(); + }); +}); diff --git a/tests/Feature/ProviderConnections/LegacyRedirectTest.php b/tests/Feature/ProviderConnections/LegacyRedirectTest.php new file mode 100644 index 0000000..7d93e42 --- /dev/null +++ b/tests/Feature/ProviderConnections/LegacyRedirectTest.php @@ -0,0 +1,65 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections') + ->assertStatus(302) + ->assertRedirect('/admin/provider-connections?tenant_id='.$tenant->external_id); + + $this->actingAs($user) + ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections/create') + ->assertStatus(302) + ->assertRedirect('/admin/provider-connections/create?tenant_id='.$tenant->external_id); + + $this->actingAs($user) + ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections/'.$connection->getKey().'/edit') + ->assertStatus(302) + ->assertRedirect('/admin/provider-connections/'.$connection->getKey().'/edit?tenant_id='.$tenant->external_id); +}); + +it('returns 404 without location header for non-workspace members on legacy routes', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + + $this->actingAs($user) + ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections') + ->assertNotFound() + ->assertHeaderMissing('Location'); +}); + +it('returns 404 without location header for non-tenant members on legacy routes', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $this->actingAs($user) + ->get('/admin/tenants/'.$tenantB->external_id.'/provider-connections') + ->assertNotFound() + ->assertHeaderMissing('Location'); +}); + +it('keeps /admin/t/{tenant}/provider-connections as not found and not redirected', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->get('/admin/t/'.$tenant->external_id.'/provider-connections') + ->assertNotFound() + ->assertHeaderMissing('Location'); +}); diff --git a/tests/Feature/ProviderConnections/ManageCapabilityEnforcementTest.php b/tests/Feature/ProviderConnections/ManageCapabilityEnforcementTest.php new file mode 100644 index 0000000..352b7a1 --- /dev/null +++ b/tests/Feature/ProviderConnections/ManageCapabilityEnforcementTest.php @@ -0,0 +1,27 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections/create?tenant_id='.(string) $tenant->external_id) + ->assertForbidden(); + + $this->actingAs($user) + ->get('/admin/provider-connections/'.$connection->getKey()) + ->assertOk(); + + $this->actingAs($user) + ->get('/admin/provider-connections/'.$connection->getKey().'/edit') + ->assertForbidden(); +}); diff --git a/tests/Feature/ProviderConnections/MvpProviderScopeTest.php b/tests/Feature/ProviderConnections/MvpProviderScopeTest.php new file mode 100644 index 0000000..903c7bc --- /dev/null +++ b/tests/Feature/ProviderConnections/MvpProviderScopeTest.php @@ -0,0 +1,40 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(CreateProviderConnection::class) + ->fillForm([ + 'display_name' => 'MVP Scope Connection', + 'entra_tenant_id' => (string) fake()->uuid(), + 'is_default' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $created = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('display_name', 'MVP Scope Connection') + ->first(); + + expect($created)->not->toBeNull(); + expect($created?->provider)->toBe('microsoft'); + + $listComponent = Livewire::test(ListProviderConnections::class); + $providerFilter = $listComponent->instance()->getTable()->getFilters()['provider'] ?? null; + + expect($providerFilter)->not->toBeNull(); + expect($providerFilter?->getOptions())->toBe(['microsoft' => 'Microsoft']); +}); diff --git a/tests/Feature/ProviderConnections/NavigationPlacementTest.php b/tests/Feature/ProviderConnections/NavigationPlacementTest.php new file mode 100644 index 0000000..68c2ffb --- /dev/null +++ b/tests/Feature/ProviderConnections/NavigationPlacementTest.php @@ -0,0 +1,32 @@ +actingAs($user) + ->get('/admin/operations') + ->assertOk(); + + $groups = app(\Filament\Navigation\NavigationManager::class)->get(); + + $settingsGroup = collect($groups) + ->first(static fn (\Filament\Navigation\NavigationGroup $group): bool => $group->getLabel() === 'Settings'); + + expect($settingsGroup)->not->toBeNull(); + + $items = collect($settingsGroup->getItems()); + + $integrationsItem = $items + ->first(static fn (\Filament\Navigation\NavigationItem $item): bool => $item->getLabel() === 'Integrations'); + + expect($integrationsItem)->not->toBeNull(); + + $childLabels = collect($integrationsItem->getChildItems()) + ->map(static fn (\Filament\Navigation\NavigationItem $item): string => $item->getLabel()) + ->values() + ->all(); + + expect($childLabels)->toContain('Provider Connections'); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php index e10dd64..af6aa3e 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php @@ -31,6 +31,11 @@ it('Spec081 returns 403 for members without provider manage capability', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + $this->actingAs($user) ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, @@ -44,4 +49,11 @@ ]) ->get(ProviderConnectionResource::getUrl('create', tenant: $tenant)) ->assertForbidden(); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertForbidden(); }); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index 5f9bbdf..b1ababd 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -47,8 +47,12 @@ ->assertForbidden(); $this->actingAs($user) - ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) ->assertOk(); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertForbidden(); }); test('readonly users can view provider connections but cannot manage them', function () { @@ -71,8 +75,12 @@ ->assertForbidden(); $this->actingAs($user) - ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) ->assertOk(); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertForbidden(); }); test('provider connection edit is not accessible cross-tenant', function () { diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php index fd8c949..9911a16 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php @@ -1,6 +1,6 @@ (int) $connection->getKey(), ]); - Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) - ->callAction('check_connection'); + Livewire::test(ListProviderConnections::class) + ->callTableAction('check_connection', $connection); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -88,10 +88,10 @@ 'provider_connection_id' => (int) $connection->getKey(), ]); - $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + $component = Livewire::test(ListProviderConnections::class); - $component->callAction('check_connection'); - $component->callAction('check_connection'); + $component->callTableAction('check_connection', $connection); + $component->callTableAction('check_connection', $connection); expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -117,11 +117,11 @@ 'status' => 'connected', ]); - Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) - ->assertActionVisible('check_connection') - ->assertActionDisabled('check_connection') - ->assertActionVisible('compliance_snapshot') - ->assertActionDisabled('compliance_snapshot'); + Livewire::test(ListProviderConnections::class) + ->assertTableActionVisible('check_connection', $connection) + ->assertTableActionDisabled('check_connection', $connection) + ->assertTableActionVisible('compliance_snapshot', $connection) + ->assertTableActionDisabled('compliance_snapshot', $connection); Queue::assertNothingPushed(); expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); diff --git a/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php b/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php index 75d3282..a1c5e69 100644 --- a/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection; +use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Jobs\ProviderConnectionHealthCheckJob; use App\Models\OperationRun; @@ -29,8 +29,8 @@ 'is_default' => true, ]); - Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) - ->callAction('check_connection'); + Livewire::test(ListProviderConnections::class) + ->callTableAction('check_connection', $connection); $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) diff --git a/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php index 3af094a..65c6cff 100644 --- a/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php +++ b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -1,6 +1,6 @@ (int) $connection->getKey(), ]); - $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); - $component->callAction('inventory_sync'); - $component->callAction('inventory_sync'); + $component = Livewire::test(ListProviderConnections::class); + $component->callTableAction('inventory_sync', $connection); + $component->callTableAction('inventory_sync', $connection); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -96,9 +96,9 @@ 'provider_connection_id' => (int) $connection->getKey(), ]); - $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); - $component->callAction('compliance_snapshot'); - $component->callAction('compliance_snapshot'); + $component = Livewire::test(ListProviderConnections::class); + $component->callTableAction('compliance_snapshot', $connection); + $component->callTableAction('compliance_snapshot', $connection); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -143,10 +143,10 @@ 'provider_connection_id' => (int) $connection->getKey(), ]); - $component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]); + $component = Livewire::test(ListProviderConnections::class); - $component->callAction('inventory_sync'); - $component->callAction('compliance_snapshot'); + $component->callTableAction('inventory_sync', $connection); + $component->callTableAction('compliance_snapshot', $connection); $inventoryRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) diff --git a/tests/Feature/ProviderConnections/RecordAccessNotFoundTest.php b/tests/Feature/ProviderConnections/RecordAccessNotFoundTest.php new file mode 100644 index 0000000..a58aea5 --- /dev/null +++ b/tests/Feature/ProviderConnections/RecordAccessNotFoundTest.php @@ -0,0 +1,29 @@ +create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections/'.$connection->getKey().'/edit') + ->assertNotFound(); + + $this->actingAs($user) + ->get('/admin/provider-connections/'.$connection->getKey()) + ->assertNotFound(); +}); diff --git a/tests/Feature/ProviderConnections/RequiredFiltersTest.php b/tests/Feature/ProviderConnections/RequiredFiltersTest.php new file mode 100644 index 0000000..20615b7 --- /dev/null +++ b/tests/Feature/ProviderConnections/RequiredFiltersTest.php @@ -0,0 +1,43 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Default Connection', + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $nonDefaultConnection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Non Default Connection', + 'provider' => 'microsoft', + 'is_default' => false, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListProviderConnections::class); + + $filterNames = array_keys($component->instance()->getTable()->getFilters()); + + expect($filterNames)->toContain('tenant', 'provider', 'status', 'health_status', 'default_only'); + + $component + ->set('tableFilters.default_only.isActive', true) + ->assertCanSeeTableRecords([$defaultConnection]) + ->assertCanNotSeeTableRecords([$nonDefaultConnection]); +}); diff --git a/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php b/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php new file mode 100644 index 0000000..c0ddb10 --- /dev/null +++ b/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php @@ -0,0 +1,65 @@ +create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'A Connection', + 'provider' => 'microsoft', + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'B Connection', + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections?tenant_id='.(string) $tenantB->external_id) + ->assertOk() + ->assertSee('B Connection') + ->assertDontSee('A Connection'); +}); + +it('returns empty list for unauthorized tenant_id query override', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'A Connection', + 'provider' => 'microsoft', + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'B Connection', + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections?tenant_id='.(string) $tenantB->external_id) + ->assertOk() + ->assertDontSee('A Connection') + ->assertDontSee('B Connection'); +}); diff --git a/tests/Feature/ProviderConnections/TenantlessListRouteTest.php b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php new file mode 100644 index 0000000..ce4ce20 --- /dev/null +++ b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php @@ -0,0 +1,25 @@ +actingAs($user) + ->get('/admin/provider-connections') + ->assertOk(); + + expect(ProviderConnectionResource::getUrl('index', panel: 'admin')) + ->toContain('/admin/provider-connections'); +}); + +it('returns 404 on the canonical tenantless route for non-workspace members', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/admin/provider-connections') + ->assertNotFound(); +}); diff --git a/tests/Feature/ProviderConnections/TenantlessListScopingTest.php b/tests/Feature/ProviderConnections/TenantlessListScopingTest.php new file mode 100644 index 0000000..c494032 --- /dev/null +++ b/tests/Feature/ProviderConnections/TenantlessListScopingTest.php @@ -0,0 +1,35 @@ +create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'Tenant A Connection', + 'provider' => 'microsoft', + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'Tenant B Connection', + 'provider' => 'microsoft', + ]); + + $this->actingAs($user) + ->get('/admin/provider-connections') + ->assertOk() + ->assertSee('Tenant A Connection') + ->assertDontSee('Tenant B Connection'); +}); diff --git a/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php index d16758d..6330d4b 100644 --- a/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php +++ b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php @@ -4,12 +4,11 @@ use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection; use App\Models\ProviderConnection; -use Filament\Actions\Action; use Filament\Facades\Filament; use Livewire\Livewire; describe('Edit provider connection actions UI enforcement', function () { - it('shows enable connection action as visible but disabled for readonly members', function () { + it('returns 403 for readonly members on the edit page', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); @@ -21,44 +20,8 @@ 'status' => 'disabled', ]); - Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) - ->assertActionVisible('enable_connection') - ->assertActionDisabled('enable_connection') - ->assertActionExists('enable_connection', function (Action $action): bool { - return $action->getTooltip() === 'You do not have permission to manage provider connections.'; - }) - ->mountAction('enable_connection') - ->callMountedAction() - ->assertSuccessful(); - - $connection->refresh(); - expect($connection->status)->toBe('disabled'); - }); - - it('shows disable connection action as visible but disabled for readonly members', function () { - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $connection = ProviderConnection::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'status' => 'connected', - ]); - - Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) - ->assertActionVisible('disable_connection') - ->assertActionDisabled('disable_connection') - ->assertActionExists('disable_connection', function (Action $action): bool { - return $action->getTooltip() === 'You do not have permission to manage provider connections.'; - }) - ->mountAction('disable_connection') - ->callMountedAction() - ->assertSuccessful(); - - $connection->refresh(); - expect($connection->status)->toBe('connected'); + $this->get('/admin/provider-connections/'.$connection->getKey().'/edit') + ->assertForbidden(); }); it('shows enable connection action as enabled for owner members', function () { diff --git a/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php b/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php new file mode 100644 index 0000000..762896c --- /dev/null +++ b/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php @@ -0,0 +1,14 @@ +actingAs($user) + ->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant)) + ->assertOk() + ->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false); +}); diff --git a/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php index f253f46..617f8ad 100644 --- a/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php +++ b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php @@ -30,7 +30,6 @@ $url = ProviderConnectionResource::getUrl('index'); - expect($url)->toContain((string) $tenant->external_id); - expect($url)->toContain('/admin/tenants/'); - expect($url)->toContain('/provider-connections'); + expect($url)->toContain('/admin/provider-connections'); + expect($url)->toContain('tenant_id='.(string) $tenant->external_id); });