feat(spec-089): provider connections tenantless UI (#107)
Implements Spec 089: moves Provider Connections to canonical tenantless route under `/admin/provider-connections`, enforces 404/403 semantics (workspace/tenant membership vs capability), adds tenant transparency (tenant column + filter + deep links), adds legacy redirects for old tenant-scoped URLs without leaking Location for 404 cases, and adds regression test coverage (RBAC semantics, filters, UI enforcement tooltips, Microsoft-only MVP scope, navigation placement). Notes: - Filament v5 / Livewire v4 compatible. - Global search remains disabled for Provider Connections. - Destructive/manage actions require confirmation and are policy-gated. Tests: - `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #107
This commit is contained in:
parent
d6e7de597a
commit
fb4de17c63
@ -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<string, string>
|
||||
*/
|
||||
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<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
if (array_key_exists('tenant', $parameters) && 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewProviderConnection extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ProviderConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
||||
@if ($isMissing)
|
||||
<div class="mt-1 text-sm text-amber-700">Needs action: no default Microsoft provider connection is configured.</div>
|
||||
@else
|
||||
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<a href="{{ $ctaUrl }}" class="inline-flex items-center rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">
|
||||
Open Provider Connections
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@unless ($isMissing)
|
||||
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
||||
<dd>{{ $provider ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt>
|
||||
<dd>{{ $status ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt>
|
||||
<dd>{{ $healthStatus ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
|
||||
<dd>{{ $lastCheck ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($lastErrorReason)
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
Last error reason: {{ $lastErrorReason }}
|
||||
</div>
|
||||
@endif
|
||||
@endunless
|
||||
</div>
|
||||
@ -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',
|
||||
|
||||
@ -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.
|
||||
@ -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)
|
||||
117
specs/089-provider-connections-tenantless-ui/data-model.md
Normal file
117
specs/089-provider-connections-tenantless-ui/data-model.md
Normal file
@ -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.
|
||||
116
specs/089-provider-connections-tenantless-ui/plan.md
Normal file
116
specs/089-provider-connections-tenantless-ui/plan.md
Normal file
@ -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
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4.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.
|
||||
37
specs/089-provider-connections-tenantless-ui/quickstart.md
Normal file
37
specs/089-provider-connections-tenantless-ui/quickstart.md
Normal file
@ -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=<tenant-external-id>`
|
||||
- Legacy redirect (must remain ≥2 releases): `/admin/tenants/<tenant-external-id>/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.
|
||||
82
specs/089-provider-connections-tenantless-ui/research.md
Normal file
82
specs/089-provider-connections-tenantless-ui/research.md
Normal file
@ -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.
|
||||
182
specs/089-provider-connections-tenantless-ui/spec.md
Normal file
182
specs/089-provider-connections-tenantless-ui/spec.md
Normal file
@ -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=<tenant_external_id_von_TenantB>` 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=<tenant_external_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.
|
||||
161
specs/089-provider-connections-tenantless-ui/tasks.md
Normal file
161
specs/089-provider-connections-tenantless-ui/tasks.md
Normal file
@ -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=<tenant>`.
|
||||
|
||||
### 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).
|
||||
@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
@ -17,15 +17,22 @@
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
test('non-members are denied access to provider connection tenant routes (404)', function () {
|
||||
test('unauthorized tenant filter yields an empty list without leaking metadata', function () {
|
||||
$tenant = Tenant::factory()->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');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
it('enforces 404 for non-members and 403 for missing manage capability on mutations', function (): void {
|
||||
$tenant = Tenant::factory()->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();
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('returns 403 for tenant members without PROVIDER_VIEW capability on list and detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
Gate::define(Capabilities::PROVIDER_VIEW, fn () => 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();
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders disabled manage actions with tooltip for tenant members without capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$connection = ProviderConnection::factory()->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();
|
||||
});
|
||||
});
|
||||
65
tests/Feature/ProviderConnections/LegacyRedirectTest.php
Normal file
65
tests/Feature/ProviderConnections/LegacyRedirectTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
it('redirects legacy tenant-scoped provider connection routes for entitled members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->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');
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
|
||||
it('returns 403 for tenant members without PROVIDER_MANAGE capability on create and edit', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$connection = ProviderConnection::factory()->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();
|
||||
});
|
||||
40
tests/Feature/ProviderConnections/MvpProviderScopeTest.php
Normal file
40
tests/Feature/ProviderConnections/MvpProviderScopeTest.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Models\ProviderConnection;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps provider scope microsoft-only in the create flow and list filter options', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->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']);
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('shows Provider Connections under the Settings → Integrations navigation section for entitled users', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->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');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -39,8 +39,8 @@
|
||||
'provider_connection_id' => (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);
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
@ -40,9 +40,9 @@
|
||||
'provider_connection_id' => (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())
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('returns 404 for direct provider connection record access by non-tenant members', function (): void {
|
||||
$tenantA = Tenant::factory()->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();
|
||||
});
|
||||
43
tests/Feature/ProviderConnections/RequiredFiltersTest.php
Normal file
43
tests/Feature/ProviderConnections/RequiredFiltersTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Models\ProviderConnection;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('exposes required provider connections list filters and applies the default-only filter', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$defaultConnection = ProviderConnection::factory()->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]);
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('uses tenant_id query override for authorized tenants', function (): void {
|
||||
$tenantA = Tenant::factory()->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');
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\User;
|
||||
|
||||
it('serves provider connections on the canonical tenantless route for workspace members', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->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();
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('scopes canonical provider connections list by tenant membership', 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' => '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');
|
||||
});
|
||||
@ -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 () {
|
||||
|
||||
14
tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
Normal file
14
tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
|
||||
it('renders provider connections CTA with canonical tenantless URL on tenant detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user