Compare commits
5 Commits
dev
...
078-operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e104101b6 | ||
|
|
94d5eab217 | ||
|
|
e57157016c | ||
|
|
3aa8f27213 | ||
|
|
58758a5bcf |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -18,8 +18,6 @@ ## Active Technologies
|
||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
|
||||
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
|
||||
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -39,9 +37,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
||||
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||
- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@ -72,7 +72,7 @@ public function selectTenant(int $tenantId): void
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
|
||||
@ -177,7 +177,7 @@ private function redirectAfterWorkspaceSelected(User $user): string
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
return TenantDashboard::getUrl(tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
|
||||
class Alerts extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
|
||||
class AuditLog extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
@ -11,18 +11,9 @@
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
|
||||
@ -8,18 +8,16 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
||||
protected static ?string $slug = 'required-permissions';
|
||||
|
||||
protected static ?string $title = 'Required permissions';
|
||||
|
||||
@ -43,28 +41,17 @@ class TenantRequiredPermissions extends Page
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
{
|
||||
return static::resolveScopedTenant();
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
@ -147,7 +134,7 @@ public function resetFilters(): void
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
@ -176,7 +163,7 @@ private function refreshViewModel(): void
|
||||
|
||||
public function reRunVerificationUrl(): ?string
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -189,26 +176,9 @@ public function reRunVerificationUrl(): ?string
|
||||
->value('id');
|
||||
|
||||
if (! is_int($connectionId)) {
|
||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
return ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
{
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -784,25 +785,25 @@ private function verificationReportViewData(): array
|
||||
$verificationReport = VerificationReportViewer::report($run);
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => (string) $run->initiator_name,
|
||||
'started_at' => $run->started_at?->toJSON(),
|
||||
'completed_at' => $run->completed_at?->toJSON(),
|
||||
'target_scope' => $targetScope,
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
'report' => $report,
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
'run' => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => (string) $run->initiator_name,
|
||||
'started_at' => $run->started_at?->toJSON(),
|
||||
'completed_at' => $run->completed_at?->toJSON(),
|
||||
'target_scope' => $targetScope,
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
'report' => $report,
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
}
|
||||
|
||||
public function acknowledgeVerificationCheckAction(): Action
|
||||
@ -1836,7 +1837,7 @@ public function completeOnboarding(): void
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
private function verificationRun(): ?OperationRun
|
||||
|
||||
@ -74,6 +74,6 @@ public function openTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -31,21 +32,16 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = ProviderConnection::class;
|
||||
|
||||
protected static ?string $slug = 'tenants/{tenant}/provider-connections';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
||||
@ -56,7 +52,7 @@ class ProviderConnectionResource extends Resource
|
||||
|
||||
protected static function hasTenantCapability(string $capability): bool
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -70,23 +66,6 @@ protected static function hasTenantCapability(string $capability): bool
|
||||
&& $resolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
{
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -122,7 +101,7 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = static::resolveScopedTenant()?->getKey();
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
@ -205,7 +184,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 = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -275,7 +254,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 = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -352,7 +331,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 = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -429,7 +408,7 @@ public static function table(Table $table): Table
|
||||
->color('primary')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -487,7 +466,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -537,7 +516,7 @@ public static function table(Table $table): Table
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -608,7 +587,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 = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -663,7 +642,7 @@ public static function table(Table $table): Table
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = static::resolveScopedTenant()?->getKey();
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
@ -685,20 +664,4 @@ public static function getPages(): array
|
||||
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
if (! array_key_exists('tenant', $parameters)) {
|
||||
$resolvedTenant = static::resolveScopedTenant();
|
||||
|
||||
if ($resolvedTenant instanceof Tenant) {
|
||||
$parameters['tenant'] = $resolvedTenant->external_id;
|
||||
}
|
||||
}
|
||||
|
||||
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,11 +17,7 @@ class CreateProviderConnection extends CreateRecord
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
@ -37,12 +33,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$record = $this->getRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
@ -81,21 +72,4 @@ protected function afterCreate(): void
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
{
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$record = $this->getRecord();
|
||||
|
||||
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
||||
@ -109,7 +109,7 @@ protected function afterSave(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
@ -128,7 +128,7 @@ protected function getHeaderActions(): array
|
||||
->where('context->provider_connection_id', (int) $record->getKey())
|
||||
->exists())
|
||||
->url(function (ProviderConnection $record): ?string {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -159,7 +159,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
@ -168,7 +168,7 @@ protected function getHeaderActions(): array
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -256,7 +256,7 @@ protected function getHeaderActions(): array
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -314,7 +314,7 @@ protected function getHeaderActions(): array
|
||||
->where('provider', $record->provider)
|
||||
->count() > 1)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -361,7 +361,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
@ -370,7 +370,7 @@ protected function getHeaderActions(): array
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -455,7 +455,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
@ -464,7 +464,7 @@ protected function getHeaderActions(): array
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -550,7 +550,7 @@ protected function getHeaderActions(): array
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -622,7 +622,7 @@ protected function getHeaderActions(): array
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -676,7 +676,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -699,7 +699,7 @@ protected function getFormActions(): array
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -719,21 +719,4 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
{
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,14 +58,8 @@ class TenantResource extends Resource
|
||||
// ... [Properties Omitted for Brevity] ...
|
||||
protected static ?string $model = Tenant::class;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static ?string $recordRouteKeyName = 'external_id';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
@ -292,7 +286,7 @@ public static function table(Table $table): Table
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
@ -411,13 +405,13 @@ public static function table(Table $table): Table
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
@ -807,7 +801,6 @@ public static function getPages(): array
|
||||
'create' => Pages\CreateTenant::route('/create'),
|
||||
'view' => Pages\ViewTenant::route('/{record}'),
|
||||
'edit' => Pages\EditTenant::route('/{record}/edit'),
|
||||
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -949,6 +942,7 @@ public static function rbacAction(): Actions\Action
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $record->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'tenant' => $record->external_id,
|
||||
'record' => $record,
|
||||
]),
|
||||
])),
|
||||
@ -1088,6 +1082,7 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $tenant->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'tenant' => $tenant->external_id,
|
||||
'record' => $tenant,
|
||||
]),
|
||||
]));
|
||||
@ -1277,6 +1272,7 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $tenant->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'tenant' => $tenant->external_id,
|
||||
'record' => $tenant,
|
||||
]),
|
||||
]));
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
class ManageTenantMemberships extends ViewTenant
|
||||
{
|
||||
protected static ?string $title = 'Tenant memberships';
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
@ -33,14 +32,6 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('provider_connections')
|
||||
->label('Provider connections')
|
||||
->icon('heroicon-o-link')
|
||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin'))
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
|
||||
@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
|
||||
@ -65,7 +65,7 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -65,10 +65,6 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||
|
||||
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
||||
? '/admin/choose-workspace'
|
||||
: '/admin/no-access';
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -91,15 +91,15 @@ public function handle(
|
||||
|
||||
$permissionService = app(TenantPermissionService::class);
|
||||
|
||||
$graphOptions = null;
|
||||
$graphOptions = null;
|
||||
|
||||
if ($result->healthy) {
|
||||
try {
|
||||
$graphOptions = app(ProviderGateway::class)->graphOptions($connection);
|
||||
} catch (\Throwable) {
|
||||
$graphOptions = null;
|
||||
if ($result->healthy) {
|
||||
try {
|
||||
$graphOptions = app(ProviderGateway::class)->graphOptions($connection);
|
||||
} catch (\Throwable) {
|
||||
$graphOptions = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$permissionComparison = $result->healthy
|
||||
? ($graphOptions === null
|
||||
@ -204,9 +204,8 @@ public function handle(
|
||||
: [[
|
||||
'label' => 'Review provider connection',
|
||||
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||
'tenant' => $tenant,
|
||||
'record' => (int) $connection->getKey(),
|
||||
], panel: 'admin'),
|
||||
], tenant: $tenant),
|
||||
]],
|
||||
],
|
||||
...$permissionChecks,
|
||||
|
||||
@ -177,11 +177,6 @@ public static function currentOrFail(): self
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'external_id';
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?Model
|
||||
{
|
||||
$field ??= $this->getRouteKeyName();
|
||||
|
||||
@ -22,7 +22,7 @@ public function viewAny(User $user): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
@ -64,7 +64,7 @@ public function create(User $user): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
@ -135,21 +135,4 @@ private function currentWorkspace(): ?Workspace
|
||||
? Workspace::query()->whereKey($workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
{
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,14 +6,15 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -37,6 +38,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
$panel = $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login(Login::class)
|
||||
@ -47,6 +49,10 @@ public function panel(Panel $panel): Panel
|
||||
|
||||
WorkspaceResource::registerRoutes($panel);
|
||||
})
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||
->searchableTenantMenu()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
@ -102,13 +108,13 @@ public function panel(Panel $panel): Panel
|
||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||
: ''
|
||||
)
|
||||
->resources([
|
||||
TenantResource::class,
|
||||
ProviderConnectionResource::class,
|
||||
])
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
TenantRequiredPermissions::class,
|
||||
TenantDashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
@ -124,6 +130,8 @@ public function panel(Panel $panel): Panel
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class TenantPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
$panel = $panel
|
||||
->default()
|
||||
->id('tenant')
|
||||
->path('admin/t')
|
||||
->login(Login::class)
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix(null)
|
||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||
->searchableTenantMenu()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::TOPBAR_START,
|
||||
fn () => view('filament.partials.context-bar')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||
: ''
|
||||
)
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
TenantDashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
}
|
||||
|
||||
return $panel;
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ final class RequiredPermissionsLinks
|
||||
*/
|
||||
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
|
||||
{
|
||||
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
|
||||
$base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id));
|
||||
|
||||
if ($filters === []) {
|
||||
return $base;
|
||||
|
||||
@ -50,50 +50,50 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||
|
||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'inventory.sync') {
|
||||
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
||||
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
|
||||
|
||||
$policyId = $context['policy_id'] ?? null;
|
||||
if (is_numeric($policyId)) {
|
||||
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
|
||||
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'directory_groups.sync') {
|
||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'drift.generate') {
|
||||
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
|
||||
|
||||
$backupSetId = $context['backup_set_id'] ?? null;
|
||||
if (is_numeric($backupSetId)) {
|
||||
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
|
||||
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
|
||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'restore.execute') {
|
||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
|
||||
|
||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||
if (is_numeric($restoreRunId)) {
|
||||
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
|
||||
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,5 @@
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\Filament\TenantPanelProvider::class,
|
||||
App\Providers\Filament\SystemPanelProvider::class,
|
||||
];
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver !== 'pgsql') {
|
||||
// SQLite doesn't enforce UUID types; other drivers are not supported in this app.
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE inventory_links ALTER COLUMN source_id TYPE text USING source_id::text');
|
||||
DB::statement('ALTER TABLE inventory_links ALTER COLUMN target_id TYPE text USING target_id::text');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Best-effort rollback: non-UUID identifiers are coerced.
|
||||
$uuidRegex = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
||||
$sentinel = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE inventory_links ALTER COLUMN source_id TYPE uuid USING (CASE WHEN source_id ~* '{$uuidRegex}' THEN source_id::uuid ELSE '{$sentinel}'::uuid END)"
|
||||
);
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE inventory_links ALTER COLUMN target_id TYPE uuid USING (CASE WHEN target_id IS NULL THEN NULL WHEN target_id ~* '{$uuidRegex}' THEN target_id::uuid ELSE NULL END)"
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -1,9 +1,10 @@
|
||||
@php
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<a
|
||||
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
|
||||
href="{{ ChooseWorkspace::getUrl() }}"
|
||||
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Switch workspace
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||
@ -34,6 +35,7 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
@ -72,7 +74,7 @@
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,10 +141,26 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/t/{tenant}/operations', fn () => redirect()->route('admin.operations.index'))
|
||||
->name('admin.operations.legacy-index');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
@ -151,10 +169,12 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||
->name('admin.monitoring.alerts');
|
||||
@ -163,10 +183,12 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
||||
->name('admin.monitoring.audit-log');
|
||||
@ -175,10 +197,11 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->name('admin.operations.view');
|
||||
@ -187,10 +210,12 @@
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-member',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||
->name('admin.workspace.managed-tenants.index');
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
# Plan: Inventory links support non-UUID IDs
|
||||
|
||||
**Branch**: `079-inventory-links-non-uuid-ids`
|
||||
**Date**: 2026-02-07
|
||||
|
||||
## Approach
|
||||
|
||||
- Add a PostgreSQL migration to change `inventory_links.source_id` and `inventory_links.target_id` from `uuid` to `text`.
|
||||
- Add a pgsql-specific test that asserts the column types are `text` and that upserting an edge with a non-UUID `target_id` does not error.
|
||||
|
||||
## Safety
|
||||
|
||||
- Change is limited to `inventory_links` columns only.
|
||||
- Unique constraint and indexes continue to function on `text` columns.
|
||||
|
||||
## Testing
|
||||
|
||||
- Pest feature test under `tests/Feature/Inventory/`.
|
||||
- Run focused test + existing inventory extraction tests.
|
||||
@ -1,22 +0,0 @@
|
||||
# Spec 079: Inventory links support non-UUID IDs
|
||||
|
||||
**Date**: 2026-02-07
|
||||
|
||||
## Problem
|
||||
|
||||
Inventory dependency extraction writes edges into `inventory_links`. Some Microsoft Graph / Intune identifiers (notably scope tag IDs) can be non-UUID strings (e.g. `"0"`). The current schema defines `inventory_links.source_id` and `inventory_links.target_id` as UUID columns, causing PostgreSQL failures when non-UUID identifiers are inserted.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow storing non-UUID identifiers in `inventory_links` without crashing inventory sync/extraction.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `inventory_links.source_id` and `inventory_links.target_id` must accept arbitrary string identifiers.
|
||||
- Existing UUID identifiers must continue to work.
|
||||
- Behavior must be covered by tests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No redesign of the dependency graph model.
|
||||
- No UI/Filament changes.
|
||||
@ -1,18 +0,0 @@
|
||||
# Tasks: Inventory links support non-UUID IDs
|
||||
|
||||
## Phase 1: Spec + setup
|
||||
|
||||
- [X] T001 Create spec folder and docs
|
||||
|
||||
## Phase 2: Tests (TDD)
|
||||
|
||||
- [X] T002 Add pgsql schema regression test in `tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
|
||||
|
||||
## Phase 3: Implementation
|
||||
|
||||
- [X] T003 Add migration to change `inventory_links.source_id` + `target_id` to `text` on PostgreSQL
|
||||
|
||||
## Phase 4: Validation
|
||||
|
||||
- [X] T004 Run tests: `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
|
||||
- [X] T005 Run Pint: `vendor/bin/sail bin pint --dirty`
|
||||
@ -1,45 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin Context APIs (Spec 080)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Minimal HTTP contract for non-Filament endpoints involved in workspace/tenant context selection.
|
||||
|
||||
Filament page/resource routes are not fully described here because they are generated by Filament.
|
||||
The spec’s primary contract for those is the route map in `routes.md`.
|
||||
|
||||
paths:
|
||||
/admin/switch-workspace:
|
||||
post:
|
||||
summary: Switch the active workspace context
|
||||
responses:
|
||||
'204': { description: Workspace switched }
|
||||
'302': { description: Redirect (if implemented) }
|
||||
'401': { description: Unauthenticated }
|
||||
'404': { description: Not a workspace member (deny-as-not-found) }
|
||||
|
||||
/admin/select-tenant:
|
||||
post:
|
||||
summary: Select the active tenant context within the selected workspace
|
||||
responses:
|
||||
'204': { description: Tenant selected }
|
||||
'302': { description: Redirect (if implemented) }
|
||||
'401': { description: Unauthenticated }
|
||||
'404': { description: Not entitled to tenant (deny-as-not-found) }
|
||||
|
||||
/admin/clear-tenant-context:
|
||||
post:
|
||||
summary: Clear the active tenant context
|
||||
responses:
|
||||
'204': { description: Tenant context cleared }
|
||||
'302': { description: Redirect (if implemented) }
|
||||
'401': { description: Unauthenticated }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
SessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: tenantpilot_session
|
||||
security:
|
||||
- SessionAuth: []
|
||||
@ -1,52 +0,0 @@
|
||||
# Route Contract — Spec 080
|
||||
|
||||
This document defines the **expected user-facing route surfaces** and the **required 404/403 semantics**.
|
||||
|
||||
## Canonical Management (workspace-scoped)
|
||||
|
||||
All of the following are under `/admin/*` and require:
|
||||
- selected workspace context
|
||||
- workspace membership (non-member → 404)
|
||||
|
||||
Routes:
|
||||
- `GET /admin/tenants`
|
||||
- `GET /admin/tenants/{tenant}`
|
||||
- `GET /admin/tenants/{tenant}/memberships`
|
||||
- `GET /admin/tenants/{tenant}/provider-connections`
|
||||
- `GET /admin/tenants/{tenant}/provider-connections/{connection}/edit`
|
||||
- `GET /admin/tenants/{tenant}/required-permissions`
|
||||
- (optional) `GET /admin/tenants/{tenant}/onboarding`
|
||||
|
||||
Identifier contract:
|
||||
- `{tenant}` MUST be `Tenant.external_id` (Entra tenant GUID)
|
||||
|
||||
Authorization contract:
|
||||
- member without capability:
|
||||
- viewing pages: allowed
|
||||
- mutating actions: 403
|
||||
|
||||
## Canonical Operate (tenant-scoped)
|
||||
|
||||
All of the following are under `/admin/t/{tenant}/*` and require:
|
||||
- selected workspace context
|
||||
- workspace membership
|
||||
- tenant entitlement (non-entitled → 404)
|
||||
|
||||
Routes (contract targets for US2 tests):
|
||||
- `GET /admin/t/{tenant}` (tenant dashboard root)
|
||||
- `GET /admin/t/{tenant}/diagnostics` (operational diagnostics page)
|
||||
|
||||
## Removed Tenant-Scoped Management (must 404)
|
||||
|
||||
The following routes MUST NOT exist (no redirects in dev stage):
|
||||
- `GET /admin/t/{tenant}/provider-connections*`
|
||||
- `GET /admin/t/{tenant}/required-permissions*`
|
||||
- `GET /admin/t/{tenant}/memberships*`
|
||||
- `GET /admin/t/{tenant}/tenants*`
|
||||
|
||||
## Monitoring
|
||||
|
||||
- `GET /admin/operations`
|
||||
- `GET /admin/operations/{run}`
|
||||
|
||||
Monitoring pages are DB-only at render time.
|
||||
@ -1,71 +0,0 @@
|
||||
# Data Model — Spec 080 Workspace-Managed Tenant Administration Migration
|
||||
|
||||
This feature is primarily a **routing + panel registration** change. No new entities are required, but the plan relies on these existing domain objects and their relationships.
|
||||
|
||||
## Entities
|
||||
|
||||
### Workspace
|
||||
- Represents the portfolio/customer context.
|
||||
- Key fields (typical): `id`, `name`, `slug` or `uuid`, `archived_at`, timestamps.
|
||||
|
||||
### WorkspaceMembership
|
||||
- Joins a `User` to a `Workspace` with a role.
|
||||
- Key fields: `id`, `workspace_id`, `user_id`, `role`, timestamps.
|
||||
- Rules:
|
||||
- Workspace membership is an isolation boundary for `/admin/*` management.
|
||||
|
||||
### Tenant (Managed Tenant)
|
||||
- Workspace-owned representation of an Entra/Intune tenant.
|
||||
- Key fields (from usage in the codebase):
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id` (canonical route identifier; Entra tenant GUID)
|
||||
- `tenant_id` (Entra tenant ID / GUID — may be same domain meaning depending on model)
|
||||
- `name`, `domain`, `environment`
|
||||
- `metadata` (JSON)
|
||||
- `archived_at` (if supported)
|
||||
- timestamps
|
||||
- Notes:
|
||||
- `{tenant}` route parameter refers to `Tenant.external_id` in both `/admin/tenants/{tenant}` and `/admin/t/{tenant}`.
|
||||
|
||||
### TenantMembership
|
||||
- Joins a `User` to a `Tenant` with a tenant role.
|
||||
- Key fields: `id`, `tenant_id`, `user_id`, `role`, timestamps.
|
||||
- Rules:
|
||||
- Tenant membership is an isolation boundary for `/admin/t/{tenant}/*`.
|
||||
- Guardrails: cannot remove/demote the last Owner (existing rule in constitution and code).
|
||||
|
||||
### ProviderConnection
|
||||
- Stores provider integration configuration for a managed tenant.
|
||||
- Key fields (from resource usage):
|
||||
- `id`, `workspace_id`, `tenant_id`
|
||||
- `provider`
|
||||
- `display_name`
|
||||
- `entra_tenant_id`
|
||||
- `is_default`
|
||||
- `status`, `health_status`
|
||||
- timestamps
|
||||
- Notes:
|
||||
- Treated as workspace-managed configuration, but scoped to a specific managed tenant via FK.
|
||||
|
||||
### AuditLog
|
||||
- Append-only record of security/management events.
|
||||
- Required attributes (per spec): `workspace_id`, `tenant_id`, `actor_id`, `action_id`, redacted metadata, timestamp.
|
||||
|
||||
### OperationRun
|
||||
- Existing observability record for long-running operations.
|
||||
- This migration itself should not introduce new runs; management page renders must be DB-only.
|
||||
|
||||
## Relationships (high level)
|
||||
|
||||
- Workspace 1—* WorkspaceMembership
|
||||
- Workspace 1—* Tenant
|
||||
- Tenant 1—* TenantMembership
|
||||
- Tenant 1—* ProviderConnection
|
||||
- Workspace 1—* ProviderConnection
|
||||
- Workspace/Tenant 1—* AuditLog
|
||||
|
||||
## State & Transitions
|
||||
|
||||
- This feature does not add new domain state transitions.
|
||||
- Any existing onboarding/activation state changes remain workspace-managed in UI (per spec) and must continue to be audited.
|
||||
@ -1,188 +0,0 @@
|
||||
# Implementation Plan: Spec 080 Workspace-Managed Tenant Administration Migration
|
||||
|
||||
**Branch**: `080-workspace-managed-tenant-admin` | **Date**: 2026-02-07 | **Spec**: [/specs/080-workspace-managed-tenant-admin/spec.md](spec.md)
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Migrate tenant administration surfaces out of tenant scope (`/admin/t/{tenant}/*`) into workspace scope (`/admin/*`) and keep tenant scope strictly for operational modules.
|
||||
|
||||
Implementation strategy:
|
||||
- Introduce a second Filament panel for tenant operations at `/admin/t/{tenant}`.
|
||||
- Convert the existing admin panel at `/admin` into a tenantless workspace management panel.
|
||||
- Register management resources/pages only in the workspace panel, ensuring tenant-scoped management routes are not registered (404).
|
||||
- Rewire internal CTAs/links (onboarding, required permissions, provider connection edit) to the new canonical workspace routes.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Tailwind v4
|
||||
**Storage**: PostgreSQL (via Sail)
|
||||
**Testing**: Pest v4 (PHPUnit v12 runner)
|
||||
**Target Platform**: Web application (server-rendered Filament/Livewire)
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**: No new performance targets; management viewers must remain DB-only at render time
|
||||
**Constraints**:
|
||||
- No external calls during render for management viewers (DB-only).
|
||||
- Dev-stage removed routes must 404 (no redirects).
|
||||
- 404 vs 403 semantics must follow RBAC-UX.
|
||||
**Scale/Scope**: Enterprise SaaS IA separation across admin surfaces (route + navigation correctness)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
Assessment (pre-Phase 0): PASS
|
||||
- No new Graph calls are introduced by this migration.
|
||||
- No new long-running operations are introduced.
|
||||
- Authorization behavior is being tightened via panel separation and route registration.
|
||||
- Monitoring/management viewers remain DB-only.
|
||||
|
||||
Notes:
|
||||
- This feature changes how routes are registered (which affects discovery, global search, and navigation). It must include regression tests ensuring removed tenant-scoped management routes do not exist.
|
||||
|
||||
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── Workspaces/
|
||||
│ │ ├── Monitoring/
|
||||
│ │ └── …
|
||||
│ ├── Resources/
|
||||
│ └── Concerns/
|
||||
├── Providers/
|
||||
│ └── Filament/
|
||||
│ ├── AdminPanelProvider.php
|
||||
│ ├── TenantPanelProvider.php
|
||||
│ └── SystemPanelProvider.php
|
||||
├── Support/
|
||||
│ └── Middleware/
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
|
||||
bootstrap/
|
||||
└── providers.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. Panel providers define panel boundaries. Routes outside Filament are defined in `routes/web.php`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations requiring justification.
|
||||
|
||||
## Phase 0 — Outline & Research
|
||||
|
||||
Outputs (written during `/speckit.plan`):
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/research.md`
|
||||
|
||||
Research questions (all resolved):
|
||||
- How to configure a tenancy panel whose URLs are `/admin/t/{tenant}` without duplicating `/t/`.
|
||||
- How to enforce route removal: do not register resources/pages in the tenant panel.
|
||||
- How to keep global search isolated by panel registration.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
Outputs:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/contracts/`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/quickstart.md`
|
||||
|
||||
### Panel Design
|
||||
|
||||
**Workspace panel (Manage)**
|
||||
- ID: `admin` (keep existing ID to avoid breaking `panel:admin` middleware usage)
|
||||
- Path: `/admin`
|
||||
- Tenancy: disabled (no `->tenant(...)`)
|
||||
- Registered artifacts: tenant management resources/pages, monitoring pages.
|
||||
|
||||
**Tenant panel (Operate)**
|
||||
- ID: `tenant` (new)
|
||||
- Path: `/admin/t`
|
||||
- Tenancy: enabled via `Tenant::class` with `slugAttribute: 'external_id'`
|
||||
- Tenant route prefix: blank/`null` so tenant routes become `/admin/t/{tenant}/...`
|
||||
- Registered artifacts: operational resources/pages only (inventory, drift, backups, policies, directory, etc.).
|
||||
- Route-shape verification is mandatory: automated regression checks must assert canonical tenant URLs are `/admin/t/{tenant}` and `/admin/t/{tenant}/...` (never `/admin/t/t/{tenant}`).
|
||||
|
||||
Laravel 11+ provider registration requirement:
|
||||
- Register the new tenant panel provider in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/bootstrap/providers.php`.
|
||||
|
||||
### Routing/Removal Mechanism
|
||||
|
||||
Tenant-scoped management routes return 404 by construction:
|
||||
- Management resources/pages are **not registered** in the tenant panel.
|
||||
- No redirects (dev-stage).
|
||||
|
||||
### Authorization Design
|
||||
|
||||
- Workspace management pages: require selected workspace + membership; non-member → 404.
|
||||
- Tenant operational routes: require workspace membership + tenant entitlement; non-entitled → 404.
|
||||
- Mutations: capability missing → 403 (server-side policy/gate); destructive-like actions require `->requiresConfirmation()`.
|
||||
|
||||
### Global Search Design
|
||||
|
||||
- Workspace panel: managed tenants are searchable (ensure resource has Edit/View page).
|
||||
- Tenant panel: tenant-management entities are not registered, so they cannot appear in global search.
|
||||
|
||||
## Phase 2 — Implementation Plan (Code + Tests)
|
||||
|
||||
Stop condition for `/speckit.plan`: this section outlines implementation, but actual task breakdown happens in `/speckit.tasks`.
|
||||
|
||||
Planned steps:
|
||||
1. Add new panel provider for tenant operations (e.g., `App\Providers\Filament\TenantPanelProvider`).
|
||||
2. Register provider in `bootstrap/providers.php` (Laravel 11+ pattern).
|
||||
3. Refactor `AdminPanelProvider` into a tenantless workspace panel:
|
||||
- remove tenancy configuration (`->tenant(...)`, tenant menu, tenant route prefix)
|
||||
- remove tenant-only middleware from the workspace panel pipeline
|
||||
4. Move/register management pages/resources into workspace panel:
|
||||
- `TenantResource` (managed tenants CRUD / manage view)
|
||||
- `ProviderConnectionResource` (workspace-managed connections by tenant)
|
||||
- required permissions viewer page
|
||||
- membership management surfaces
|
||||
- onboarding/activation surfaces
|
||||
5. Move/register operational pages/resources into the tenant panel.
|
||||
6. Rewire internal links/CTAs that currently build tenant-scoped management URLs to the new workspace-managed canonical URLs.
|
||||
7. Add regression tests (Pest) to cover:
|
||||
- workspace member can access `/admin/tenants*`
|
||||
- non-member gets 404
|
||||
- tenant entitlement required for `/admin/t/{tenant}/...`
|
||||
- canonical tenant panel route shape is `/admin/t/{tenant}/...` (no duplicated `/t`)
|
||||
- tenant-scoped management routes are missing (404)
|
||||
- link rewiring expectations (where feasible)
|
||||
8. Run targeted test pack and Pint:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/...`
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
@ -1,36 +0,0 @@
|
||||
# Quickstart — Spec 080 Workspace-Managed Tenant Administration Migration
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Laravel Sail is used for local dev.
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start services: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
|
||||
## What to verify manually
|
||||
|
||||
1. Select a workspace (existing flow)
|
||||
2. Visit workspace-managed tenant admin:
|
||||
- `/admin/tenants`
|
||||
- `/admin/tenants/{tenant}`
|
||||
3. Visit tenant operate routes only when entitled:
|
||||
- `/admin/t/{tenant}/…`
|
||||
4. Confirm removed tenant-scoped management URLs return 404:
|
||||
- `/admin/t/{tenant}/provider-connections`
|
||||
- `/admin/t/{tenant}/required-permissions`
|
||||
|
||||
## Run targeted tests
|
||||
|
||||
- Run Spec 080 test file (to be created in Phase 2):
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php`
|
||||
|
||||
## Formatting
|
||||
|
||||
- Format touched files: `vendor/bin/sail bin pint --dirty`
|
||||
|
||||
## Deployment note
|
||||
|
||||
This feature changes route registration via Filament panel providers.
|
||||
No migrations are expected.
|
||||
@ -1,45 +0,0 @@
|
||||
# Research — Spec 080 Workspace-Managed Tenant Administration Migration
|
||||
|
||||
Date: 2026-02-07
|
||||
|
||||
## Decision 1 — Two Filament panels (workspace + tenant)
|
||||
|
||||
- Decision: Implement a workspace (tenantless) panel at `/admin` and a tenant (tenancy) panel at `/admin/t/{tenant}`.
|
||||
- Rationale: This makes “Manage vs Operate” enforceable via route registration (removed routes 404), avoids tenant-context chicken-and-egg, and matches Filament-native separation.
|
||||
- Alternatives considered:
|
||||
- Keep a single panel and conditionally hide resources when tenant is selected: rejected because routes still exist and semantics are harder to enforce.
|
||||
|
||||
## Decision 2 — Tenant panel path configuration to achieve `/admin/t/{tenant}`
|
||||
|
||||
- Decision: Configure the tenant panel with `path('admin/t')`, `tenant(Tenant::class, slugAttribute: 'external_id')`, and **no tenant route prefix** (`tenantRoutePrefix(null)` / default).
|
||||
- Rationale: Filament’s tenancy routing adds `/{tenant}` after the panel path, and the optional `tenantRoutePrefix` is only prepended when it is “filled”. Leaving it blank yields `/admin/t/{tenant}` (not `/admin/t/t/{tenant}`).
|
||||
- Alternatives considered:
|
||||
- Keep the existing `path('admin') + tenantRoutePrefix('t')` for a tenant panel: rejected because it would conflict with the workspace panel at the same path.
|
||||
|
||||
## Decision 3 — Workspace context in URLs
|
||||
|
||||
- Decision: Workspace-managed tenant management uses `/admin/tenants*` (workspace selected in session/context; enforced by middleware).
|
||||
- Rationale: Matches current app pattern (`ensure-workspace-selected`) and reduces URL churn.
|
||||
- Alternatives considered:
|
||||
- `/admin/w/{workspace}/tenants*`: rejected because it’s not canonical for this feature and increases link surface.
|
||||
|
||||
## Decision 4 — View vs mutation authorization in management scope
|
||||
|
||||
- Decision: Management pages are viewable for workspace members; **mutations** are capability-gated (403).
|
||||
- Rationale: Aligns with RBAC-UX guidance: membership is isolation (404), capability is authorization (403).
|
||||
- Alternatives considered:
|
||||
- Require capability to view: rejected to avoid “mysterious forbidden” UX and because spec explicitly reserves 403 for mutations.
|
||||
|
||||
## Decision 5 — Global search isolation
|
||||
|
||||
- Decision: Managed tenants are searchable in the workspace panel only; the tenant panel must not expose tenant-management entities via global search.
|
||||
- Rationale: Prevents cross-scope discovery leaks and aligns with “Manage is workspace-scoped”.
|
||||
- Alternatives considered:
|
||||
- Disable global search entirely: rejected (spec wants workspace search behavior).
|
||||
|
||||
## Decision 6 — How removed tenant-scoped management routes become 404
|
||||
|
||||
- Decision: Do not register tenant-management resources/pages in the tenant panel.
|
||||
- Rationale: In Filament, unregistered resources/pages simply do not have routes; this is the cleanest dev-stage “no redirects” behavior.
|
||||
- Alternatives considered:
|
||||
- Redirect legacy tenant-scoped routes: rejected (explicit non-goal).
|
||||
@ -1,238 +0,0 @@
|
||||
# Feature Specification: Workspace-Managed Tenant Administration Migration
|
||||
|
||||
**Feature Branch**: `080-workspace-managed-tenant-admin`
|
||||
**Created**: 2026-02-07
|
||||
**Status**: Draft (implementation-ready)
|
||||
**Input**: User description: "Make Manage workspace-scoped (/admin) and Operate tenant-scoped (/admin/t/{tenant}). Eliminate management CRUD from /admin/t/*"
|
||||
|
||||
This feature migrates all tenant administration surfaces out of the Filament tenant scope (`/admin/t/{tenant}/*`) into workspace-scoped routes (`/admin/*`). Tenant scope is reserved strictly for operational modules.
|
||||
|
||||
**Separation rule (normative):**
|
||||
- Workspace panel (`/admin/*`) = Manage (tenants, memberships, provider connections, required permissions, onboarding/activation, monitoring).
|
||||
- Tenant panel (`/admin/t/{tenant}/*`) = Operate (inventory, drift, policies, backups, directory, etc.).
|
||||
|
||||
**Out of scope for this feature:** introducing a new tenant panel “Home” page at `/admin/t/{tenant}`.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-07
|
||||
|
||||
- Q: Should management pages be viewable for workspace members without manage capabilities? → A: Yes. Management pages are viewable for workspace members; only mutations are capability-gated (403).
|
||||
- Q: Should this feature include a dedicated tenant panel “Home” page at `/admin/t/{tenant}`? → A: No. Do not add a new tenant home page in this feature.
|
||||
- Q: For workspace-managed routes like `/admin/tenants/{tenant}`, what should `{tenant}` be? → A: `Tenant.external_id` (Entra tenant GUID), same identifier used by Filament tenancy under `/admin/t/{tenant}`.
|
||||
- Q: Should “Managed Tenants” appear in Filament Global Search? → A: Yes, in the workspace panel only; tenant panel must not expose tenant-management entities in search.
|
||||
- Q: For workspace-managed tenant management routes, what is the canonical URL shape? → A: `/admin/tenants*` (workspace is selected in context/session; middleware enforces it), not `/admin/w/{workspace}/tenants*`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - Manage tenants from workspace scope (Priority: P1)
|
||||
|
||||
As a workspace member, I can manage (view/create/configure) managed tenants under `/admin/tenants*` without needing to enter tenant scope.
|
||||
|
||||
**Why this priority**: It removes the “henne-ei” context issue and makes `/admin` the canonical management surface.
|
||||
|
||||
**Independent Test**: Fully testable via HTTP requests asserting 200/404 and basic page visibility under `/admin/tenants*`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member, **When** I visit `/admin/tenants`, **Then** I can access the Tenants management list.
|
||||
2. **Given** I am a workspace member, **When** I visit `/admin/tenants/{tenant}`, **Then** I can access the Tenant management overview.
|
||||
3. **Given** I am not a workspace member, **When** I visit `/admin/tenants` or `/admin/tenants/{tenant}`, **Then** I receive a 404 (deny-as-not-found).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Operate tenant modules only when entitled (Priority: P2)
|
||||
|
||||
As a user, I can operate inside a managed tenant under `/admin/t/{tenant}/*` only when I’m entitled to that tenant.
|
||||
|
||||
**Why this priority**: It preserves tenant isolation and makes 404/403 semantics predictable.
|
||||
|
||||
**Independent Test**: Fully testable via an operational page route asserting 200 for entitled users and 404 for non-entitled users.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member and entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I can access it.
|
||||
2. **Given** I am a workspace member but not entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I receive a 404 (deny-as-not-found).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Tenant-scoped management routes are removed (Priority: P3)
|
||||
|
||||
As a user, I cannot access tenant-scoped management CRUD routes anymore; they are removed and should not resolve.
|
||||
|
||||
**Why this priority**: It enforces IA separation via route registration (not just UI hiding).
|
||||
|
||||
**Independent Test**: Fully testable via HTTP 404 assertions for a set of removed routes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** any user, **When** I request `/admin/t/{tenant}/provider-connections`, **Then** I receive 404 because the route does not exist.
|
||||
2. **Given** any user, **When** I request `/admin/t/{tenant}/required-permissions`, **Then** I receive 404 because the route does not exist.
|
||||
3. **Given** any user, **When** I request a tenant-scoped tenant-management route (e.g. `/admin/t/{tenant}/tenants/*`), **Then** I receive 404 because the route does not exist.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Management actions enforce capability semantics (Priority: P2)
|
||||
|
||||
As a workspace member, I can see management pages, but mutations are forbidden unless I have the required capability.
|
||||
|
||||
**Why this priority**: It preserves enterprise RBAC semantics (404 for non-membership; 403 for missing capability on mutation).
|
||||
|
||||
**Independent Test**: Test a representative management mutation and assert 403 when capability is missing.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member without the relevant manage capability, **When** I attempt a management mutation (e.g., change role, set default connection), **Then** the server responds 403.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Global search isolation (Priority: P2)
|
||||
|
||||
As a user, global search does not leak tenant management entities across scopes.
|
||||
|
||||
**Why this priority**: It prevents discovery leaks and aligns with deny-as-not-found semantics.
|
||||
|
||||
**Independent Test**: Test that workspace global search can find allowed tenants; tenant panel search does not expose tenant-management entities; non-members discover nothing.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member, **When** I use workspace global search, **Then** I can find tenants I can access.
|
||||
2. **Given** I am in tenant panel, **When** I use global search, **Then** it does not expose tenant-management entities.
|
||||
3. **Given** I am not a workspace member, **When** I use global search, **Then** I do not discover tenant existence.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Direct navigation to removed tenant-scoped management URLs.
|
||||
- Stale internal CTAs/links that previously pointed at `/admin/t/{tenant}` management screens.
|
||||
- Tenant slug/identifier mismatch (requesting a tenant not belonging to the active workspace context).
|
||||
- Cross-scope leakage via global search results or navigation items.
|
||||
- Non-member users attempting to infer tenant/workspace existence.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
**Principles (normative):**
|
||||
- “Tenants” means the workspace-managed tenants list (CRUD under `/admin/tenants*`).
|
||||
- “Switch tenant” is a context action in tenant scope; it must not behave like a CRUD list.
|
||||
- Management must not depend on being in tenant scope; tenant scope must not expose tenant administration CRUD.
|
||||
|
||||
**Routing / IA (normative):**
|
||||
- Workspace management is canonical under `/admin/*`:
|
||||
- `/admin/tenants` (list/create)
|
||||
- `/admin/tenants/{tenant}` (manage overview)
|
||||
- `/admin/tenants/{tenant}/memberships`
|
||||
- `/admin/tenants/{tenant}/provider-connections`
|
||||
- `/admin/tenants/{tenant}/required-permissions`
|
||||
- `/admin/tenants/{tenant}/onboarding` (optional entry)
|
||||
- `/admin/operations` and `/admin/operations/{run}` (monitoring)
|
||||
- Tenant scope is operate-only under `/admin/t/{tenant}/*` (inventory, drift, policies, backups/restore, directory, etc.).
|
||||
|
||||
**Workspace context:**
|
||||
- Workspace-scoped management routes MUST use the `/admin/tenants*` shape and rely on the selected workspace context (e.g., middleware) rather than including `{workspace}` in every management URL.
|
||||
|
||||
**Route parameter identity (normative):**
|
||||
- `{tenant}` in both workspace-managed routes (`/admin/tenants/{tenant}/*`) and tenant-scoped routes (`/admin/t/{tenant}/*`) MUST refer to the managed tenant identifier `Tenant.external_id` (Entra tenant GUID).
|
||||
|
||||
**Authorization semantics (mandatory):**
|
||||
- Non-member / not entitled to scope: 404 (deny-as-not-found).
|
||||
- Member lacking capability: 403 for management mutations.
|
||||
- Workspace management pages are viewable for workspace members even without manage capabilities; only mutations are capability-gated.
|
||||
- All checks are server-side via Policies/Gates, using the canonical capability registry (no raw strings).
|
||||
|
||||
**Panel structure (native):**
|
||||
- Two Filament panels are used:
|
||||
- Workspace panel is tenantless and mounted at `/admin`.
|
||||
- Tenant panel is tenancy-mounted at `/admin/t/{tenant}`.
|
||||
- Each resource/page is registered only in the appropriate panel.
|
||||
|
||||
**Global search isolation (mandatory):**
|
||||
- Global search behavior is defined normatively in **FR-080-014**; this section captures intent only (no cross-scope discovery leaks).
|
||||
|
||||
**Route removal (dev-stage requirement):**
|
||||
- Tenant-scoped management routes must not exist after migration (expected 404):
|
||||
- `/admin/t/{tenant}/provider-connections*`
|
||||
- `/admin/t/{tenant}/required-permissions*`
|
||||
- `/admin/t/{tenant}/memberships*`
|
||||
- `/admin/t/{tenant}/tenants*` (any management tenancy list/view/edit)
|
||||
|
||||
**FR-080-001**: The system MUST expose tenant administration surfaces only under workspace scope (`/admin/tenants*`).
|
||||
**FR-080-002**: The system MUST expose tenant operations only under tenant scope (`/admin/t/{tenant}/*`).
|
||||
**FR-080-003**: All workspace management pages MUST require active workspace context + workspace membership; non-member returns 404.
|
||||
**FR-080-004**: All tenant operational routes MUST require workspace membership + tenant entitlement; non-entitled returns 404.
|
||||
**FR-080-005**: Management mutations MUST return 403 when capability is missing (in addition to any UI disabling).
|
||||
|
||||
**Management surfaces (workspace-scoped):**
|
||||
**FR-080-006**: Tenants list and tenant manage overview MUST exist under `/admin/tenants` and `/admin/tenants/{tenant}`.
|
||||
**FR-080-007**: Provider Connections CRUD MUST exist only under `/admin/tenants/{tenant}/provider-connections*`.
|
||||
**FR-080-008**: Required Permissions remediation UI MUST exist only under `/admin/tenants/{tenant}/required-permissions` and render DB-only.
|
||||
**FR-080-009**: Tenant Memberships/Roles management MUST exist only under `/admin/tenants/{tenant}/memberships` and audit changes.
|
||||
**FR-080-010**: Activation/onboarding controls MUST live under workspace-managed tenant pages/wizard entry (not in tenant scope).
|
||||
|
||||
**Navigation (enterprise):**
|
||||
**FR-080-011**: Workspace navigation MUST include Tenants (manage) and Monitoring (Operations, Alerts, Audit Log).
|
||||
**FR-080-012**: Tenant navigation MUST include only operational modules; no tenant CRUD entry appears in tenant sidebar.
|
||||
**FR-080-014**: Workspace panel global search MAY return managed tenants only when the user can access them; tenant panel global search MUST NOT include tenant-management resources.
|
||||
**FR-080-015**: Workspace-managed tenant management routes MUST be reachable under `/admin/tenants*` with a selected workspace context; `/admin/w/{workspace}` is not the canonical management route shape for this feature.
|
||||
|
||||
**Filament constraint (hard rule):** any globally searchable Resource MUST have an Edit or View page; otherwise global search will return no results.
|
||||
|
||||
**Observability & Audit (minimal, mandatory):**
|
||||
**FR-080-013**: The system MUST emit audit events for management mutations (tenant changes, role changes, provider connection CRUD/default selection, activation/onboarding state changes) with redacted fields only.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: Portfolio/customer context; primary security boundary for `/admin/*`.
|
||||
- **ManagedTenant**: Workspace-owned representation of an Entra/Intune tenant (identified by Entra tenant GUID).
|
||||
- **TenantMembership**: Assignment of a user to a managed tenant with a role (Owner/Manager/Operator/Readonly).
|
||||
- **ProviderConnection**: Stored connection/config for provider integration, scoped to a managed tenant.
|
||||
- **Capability**: Canonical capability registry entries used for authorization decisions.
|
||||
- **AuditLog entry**: Append-only event record for management mutations (redacted).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-080-001**: Tenant management surfaces are reachable only under `/admin/tenants*`.
|
||||
- **SC-080-002**: Tenant operational surfaces are reachable only under `/admin/t/{tenant}/*`.
|
||||
- **SC-080-003**: Tenant-scoped management routes are not registered and return 404.
|
||||
- **SC-080-004**: Authorization semantics are consistent: non-member 404, missing capability yields 403 on mutations.
|
||||
- **SC-080-005**: Internal CTAs/links to manage provider connections/required permissions point to workspace-managed routes.
|
||||
@ -1,200 +0,0 @@
|
||||
---
|
||||
|
||||
description: "Task breakdown for Spec 080 implementation"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Workspace-Managed Tenant Administration Migration (Spec 080)
|
||||
|
||||
**Input**: Design documents from `/specs/080-workspace-managed-tenant-admin/` (`plan.md`, `spec.md`, `contracts/routes.md`, `research.md`, `data-model.md`, `quickstart.md`)
|
||||
|
||||
**Non-negotiables (repo rules)**
|
||||
- Filament v5 + Livewire v4.0+ only.
|
||||
- Laravel 11+: Filament panel providers are registered in `bootstrap/providers.php`.
|
||||
- RBAC-UX semantics: non-member/non-entitled → 404; member missing capability on mutation → 403.
|
||||
- Removed tenant-scoped management routes must not be registered (404, no redirects).
|
||||
- Tests are REQUIRED (Pest).
|
||||
|
||||
## Phase 1: Setup (Panel Scaffolding)
|
||||
|
||||
**Purpose**: Introduce the tenant operations panel without changing behavior yet.
|
||||
|
||||
- [X] T001 Create tenant operations panel provider in app/Providers/Filament/TenantPanelProvider.php
|
||||
- [X] T002 Register new provider in bootstrap/providers.php (add App\Providers\Filament\TenantPanelProvider::class)
|
||||
|
||||
**Checkpoint**: App boots with both panels registered.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the manage vs operate separation mechanisms (routing + middleware + discovery boundaries).
|
||||
|
||||
- [X] T003 Refactor workspace panel to be tenantless in app/Providers/Filament/AdminPanelProvider.php (remove ->tenant(...), ->tenantRoutePrefix('t'), tenant menu/searchable menu)
|
||||
- [X] T004 Update workspace panel middleware stack in app/Providers/Filament/AdminPanelProvider.php (remove ensure-filament-tenant-selected and App\Support\Middleware\DenyNonMemberTenantAccess for workspace panel)
|
||||
- [X] T005 Configure tenant panel tenancy + middleware in app/Providers/Filament/TenantPanelProvider.php (enable ->tenant(App\Models\Tenant::class, slugAttribute: 'external_id'), enforce canonical path `/admin/t/{tenant}/...`, and add ensure-filament-tenant-selected + DenyNonMemberTenantAccess)
|
||||
- [X] T006 Constrain resource/page discovery so management artifacts do not get registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (use dedicated discovery roots like app/Filament/Tenant/**)
|
||||
- [X] T007 [P] Add a dedicated base feature test file tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (placeholder tests + factories usage notes)
|
||||
- [X] T030 [P] [FOUNDATION] Add route-shape regression tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php asserting tenant routes resolve as `/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics` and never `/admin/t/t/{tenant}/...`
|
||||
|
||||
**Checkpoint**: Workspace panel does not require tenant context; tenant panel is isolated by discovery roots.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Manage tenants from workspace scope (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Tenants management is canonical under `/admin/tenants*` without needing tenant scope.
|
||||
|
||||
**Independent Test**: As a workspace member, `GET /admin/tenants` returns 200; non-member returns 404.
|
||||
|
||||
### Tests (Pest) — US1
|
||||
|
||||
- [X] T008 [P] [US1] Add access tests for workspace-managed tenant list in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
|
||||
- [X] T009 [P] [US1] Add access tests for workspace-managed tenant view route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
|
||||
- [X] T031 [P] [US1] Add access tests for workspace-managed memberships route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/tenants/{tenant}/memberships`, member 200, non-member 404)
|
||||
|
||||
### Implementation — US1
|
||||
|
||||
- [X] T010 [US1] Move/rename management routes to match /admin/tenants* in app/Filament/Resources/TenantResource.php (ensure resource slug becomes tenants under workspace panel)
|
||||
- [X] T011 [US1] Ensure TenantResource is registered only in workspace panel (update app/Providers/Filament/AdminPanelProvider.php registration/discovery strategy)
|
||||
- [X] T012 [US1] Ensure tenant route parameter identity uses Tenant.external_id in app/Models/Tenant.php (route key name) OR in TenantResource route binding configuration
|
||||
- [X] T032 [US1] Ensure memberships management surface exists only under workspace scope in app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php and app/Filament/Resources/TenantResource.php (`/admin/tenants/{tenant}/memberships`)
|
||||
|
||||
**Checkpoint**: `/admin/tenants` works in the workspace panel.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Operate inside tenant scope only when entitled (Priority: P2)
|
||||
|
||||
**Goal**: Operational pages remain under `/admin/t/{tenant}/*` and return 404 when user is not entitled.
|
||||
|
||||
**Independent Test**: Entitled user can access `GET /admin/t/{tenant}` and `GET /admin/t/{tenant}/diagnostics`; non-entitled user gets 404.
|
||||
|
||||
### Tests (Pest) — US2
|
||||
|
||||
- [X] T013 [P] [US2] Add tenant entitlement tests for concrete operational routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics`: entitled 200, non-entitled 404)
|
||||
|
||||
### Implementation — US2
|
||||
|
||||
- [X] T014 [US2] Register/relocate tenant operational dashboard page into tenant panel in app/Filament/Pages/TenantDashboard.php and app/Providers/Filament/TenantPanelProvider.php
|
||||
- [X] T015 [US2] Ensure tenant selection redirects into tenant panel routes in app/Filament/Pages/ChooseTenant.php (redirect should target /admin/t/{tenant}/...)
|
||||
|
||||
**Checkpoint**: The contracted operational routes are reachable only when entitled.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Remove tenant-scoped management CRUD routes (Priority: P3)
|
||||
|
||||
**Goal**: Tenant-scoped management URLs do not resolve because resources/pages are not registered in tenant panel.
|
||||
|
||||
**Independent Test**: Requests to removed routes return 404 (route does not exist).
|
||||
|
||||
### Tests (Pest) — US3
|
||||
|
||||
- [X] T016 [P] [US3] Add 404 regression tests for removed routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (e.g. /admin/t/{tenant}/provider-connections, /admin/t/{tenant}/required-permissions)
|
||||
- [X] T033 [P] [US3] Add tenant navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php ensuring tenant panel sidebar does not expose tenant-management entries (Tenants, Provider Connections, Memberships)
|
||||
|
||||
### Implementation — US3
|
||||
|
||||
- [X] T017 [US3] Ensure management resources are not discovered/registered in tenant panel (verify TenantResource + ProviderConnectionResource not in tenant discovery roots in app/Providers/Filament/TenantPanelProvider.php)
|
||||
- [X] T018 [US3] Ensure required permissions page is not registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (and/or relocate page class under workspace-managed location)
|
||||
- [X] T034 [US3] Ensure tenant panel navigation is operate-only by construction in app/Providers/Filament/TenantPanelProvider.php (no TenantResource/ProviderConnectionResource/TenantMemberships registration)
|
||||
|
||||
**Checkpoint**: Removed tenant-scoped management paths return 404 (no redirects).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Management actions enforce capability semantics (Priority: P2)
|
||||
|
||||
**Goal**: Workspace members can view management pages; mutations are forbidden (403) unless capability is present.
|
||||
|
||||
**Independent Test**: A representative management mutation returns 403 for a workspace member missing capability.
|
||||
|
||||
### Tests (Pest) — US4
|
||||
|
||||
- [X] T019 [P] [US4] Add mutation authorization test asserting 403 (member missing capability) in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
|
||||
- [X] T035 [P] [US4] Add audit assertion test for tenant membership mutation in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (writes redacted AuditLog with stable action ID)
|
||||
|
||||
### Implementation — US4
|
||||
|
||||
- [X] T020 [US4] Audit management mutations in app/Services/Intune/AuditLogger.php (or existing audit service) for provider connection + membership changes with stable action IDs
|
||||
- [X] T021 [US4] Ensure destructive-like actions use ->requiresConfirmation() in app/Filament/Resources/TenantResource.php and app/Filament/Resources/ProviderConnectionResource.php
|
||||
|
||||
**Checkpoint**: 403 vs 404 semantics match spec for mutations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Global search isolation (Priority: P2)
|
||||
|
||||
**Goal**: Tenant management entities are searchable only in workspace panel; tenant panel global search does not expose them.
|
||||
|
||||
**Independent Test**: Workspace panel global search can resolve TenantResource results; tenant panel global search does not include TenantResource/ProviderConnectionResource.
|
||||
|
||||
### Tests (Pest) — US5
|
||||
|
||||
- [X] T022 [P] [US5] Add global search scoping tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (workspace panel finds tenant; tenant panel does not expose management resources)
|
||||
|
||||
### Implementation — US5
|
||||
|
||||
- [X] T023 [US5] Ensure TenantResource has a View/Edit page for global search compliance in app/Filament/Resources/TenantResource/Pages/*
|
||||
- [X] T024 [US5] Ensure tenant panel does not register tenant-management resources/pages (verify discovery roots + resource registration in app/Providers/Filament/TenantPanelProvider.php)
|
||||
|
||||
**Checkpoint**: Global search results do not leak across scopes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting
|
||||
|
||||
**Purpose**: Link rewiring, consistency, formatting, and minimal regression validation.
|
||||
|
||||
- [X] T025 [P] Rewire internal CTAs from tenant-scoped management URLs to workspace-managed routes in app/Filament/Pages/Workspaces/ManagedTenantsLanding.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [X] T026 [P] Update required permissions viewer to be workspace-managed + DB-only in app/Filament/Pages/TenantRequiredPermissions.php (accept tenant via route param, avoid Tenant::current())
|
||||
- [X] T027 [P] Update provider connections to support workspace-managed per-tenant routes in app/Filament/Resources/ProviderConnectionResource.php (query should use route tenant param when not in tenancy)
|
||||
- [X] T038 [P] Add Provider Connections CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (links to /admin/tenants/{tenant}/provider-connections)
|
||||
- [X] T036 [P] Add workspace navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php to assert Tenants + Monitoring entries are present in workspace panel after panel split
|
||||
- [X] T037 [P] Extend RBAC regression coverage for panel boundaries in tests/Feature/TenantRBAC/* (deny-as-not-found across workspace/tenant routing boundaries)
|
||||
- [X] T028 Run formatting on touched files with vendor/bin/sail bin pint --dirty (formats app/** and tests/**)
|
||||
- [X] T029 Run focused tests with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
|
||||
- [X] T039 Run formatting on touched files again (post-T038) with vendor/bin/sail bin pint --dirty
|
||||
- [X] T040 Re-run focused spec tests (post-T038) with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story Dependency Graph
|
||||
|
||||
- Setup (Phase 1) → Foundational (Phase 2) → US1 (MVP)
|
||||
- US2 depends on Phase 2
|
||||
- US3 depends on Phase 2 (route removal works once panel discovery boundaries are in place)
|
||||
- US4 depends on US1 (needs workspace management routes/actions)
|
||||
- US5 depends on Phase 2 + US1 (workspace management resources must exist)
|
||||
- Polish tasks depend on US1–US5 completion for stable regression assertions (T036, T037).
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- After Phase 2 completes:
|
||||
- US1 tests (T008–T009, T031) can be written in parallel with US1 implementation (T010–T012, T032).
|
||||
- US2 (T013–T015) can proceed in parallel with US1.
|
||||
- US3 tests (T016, T033) can be added early as regression guards.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US1
|
||||
|
||||
- Write tests: T008 + T009 in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
|
||||
- Implement routes: T010 in app/Filament/Resources/TenantResource.php
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP Scope
|
||||
|
||||
- Complete Phase 1 + Phase 2 + US1 (T001–T012)
|
||||
- Validate with T029 and manual checks from quickstart.md
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- Add US2 + US3 next (routing guarantees + entitlement semantics)
|
||||
- Then US4 (mutation semantics + audit) and US5 (global search isolation)
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyExtractionService;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('stores non-UUID identifiers in inventory_links on PostgreSQL', function () {
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$item = InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => '11111111-1111-1111-1111-111111111111',
|
||||
]);
|
||||
|
||||
/** @var DependencyExtractionService $service */
|
||||
$service = app(DependencyExtractionService::class);
|
||||
|
||||
$service->extractForPolicyData($item, [
|
||||
'id' => $item->external_id,
|
||||
'roleScopeTagIds' => ['0'],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$columnTypes = collect(DB::select(
|
||||
"select column_name, data_type from information_schema.columns where table_name = 'inventory_links' and column_name in ('source_id', 'target_id')"
|
||||
))
|
||||
->mapWithKeys(fn (object $row) => [(string) $row->column_name => (string) $row->data_type]);
|
||||
|
||||
expect($columnTypes->get('source_id'))->toBe('text')
|
||||
->and($columnTypes->get('target_id'))->toBe('text');
|
||||
}
|
||||
|
||||
expect(
|
||||
InventoryLink::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source_type', 'inventory_item')
|
||||
->where('source_id', $item->external_id)
|
||||
->where('relationship_type', RelationshipType::ScopedBy->value)
|
||||
->where('target_id', '0')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
@ -1,282 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('allows workspace members to open the workspace-managed tenants index', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/tenants')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the workspace-managed tenants index', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/tenants')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows workspace members to open the workspace-managed tenant view route', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}")
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('exposes a provider connections link from the workspace-managed tenant view page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}")
|
||||
->assertOk()
|
||||
->assertSee("/admin/tenants/{$tenant->external_id}/provider-connections", false);
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the workspace-managed tenant view route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('exposes memberships management under workspace scope', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}/memberships")
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('requires tenant entitlement for the contracted tenant operational routes', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'external_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
]);
|
||||
|
||||
[$entitledUser] = createUserWithTenant($tenant, role: 'readonly');
|
||||
|
||||
$nonEntitledUser = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $nonEntitledUser->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($entitledUser)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($entitledUser)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($nonEntitledUser)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($nonEntitledUser)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/t/{$tenant->external_id}/diagnostics")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('removes tenant-scoped management routes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}/provider-connections")
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}/memberships")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('serves provider connection management under workspace-managed tenant routes only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}/provider-connections")
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}/provider-connections/{$connection->getKey()}/edit")
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('returns 403 for workspace members missing mutation capability on provider connections', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}/provider-connections")
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}/provider-connections/create")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('writes canonical membership audit entries for membership mutations', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$member = User::factory()->create();
|
||||
|
||||
/** @var TenantMembershipManager $manager */
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$membership = $manager->addMember(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
member: $member,
|
||||
role: 'readonly',
|
||||
source: 'manual',
|
||||
);
|
||||
|
||||
$manager->changeRole(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
membership: $membership,
|
||||
newRole: 'operator',
|
||||
);
|
||||
|
||||
$manager->removeMember(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
membership: $membership,
|
||||
);
|
||||
|
||||
$actions = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('action', [
|
||||
AuditActionId::TenantMembershipAdd->value,
|
||||
AuditActionId::TenantMembershipRoleChange->value,
|
||||
AuditActionId::TenantMembershipRemove->value,
|
||||
])
|
||||
->pluck('action')
|
||||
->all();
|
||||
|
||||
expect($actions)->toContain(AuditActionId::TenantMembershipAdd->value);
|
||||
expect($actions)->toContain(AuditActionId::TenantMembershipRoleChange->value);
|
||||
expect($actions)->toContain(AuditActionId::TenantMembershipRemove->value);
|
||||
});
|
||||
|
||||
it('keeps workspace navigation entries after panel split', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/tenants')
|
||||
->assertOk()
|
||||
->assertSee('Tenants')
|
||||
->assertSee('Operations')
|
||||
->assertSee('Alerts')
|
||||
->assertSee('Audit Log');
|
||||
});
|
||||
|
||||
it('does not expose tenant-management resources in tenant panel registration or navigation URLs', function (): void {
|
||||
$tenantPanelResources = Filament::getPanel('tenant')->getResources();
|
||||
|
||||
expect($tenantPanelResources)->not->toContain(TenantResource::class);
|
||||
expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertOk()
|
||||
->assertDontSee("/admin/t/{$tenant->external_id}/provider-connections", false)
|
||||
->assertDontSee("/admin/t/{$tenant->external_id}/tenants", false);
|
||||
});
|
||||
|
||||
it('keeps global search scoped to workspace-managed tenant resources only', function (): void {
|
||||
[$workspaceUser, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($workspaceUser);
|
||||
|
||||
$results = TenantResource::getGlobalSearchResults((string) $tenant->name);
|
||||
|
||||
expect($results->count())->toBeGreaterThan(0);
|
||||
|
||||
$nonMember = User::factory()->create();
|
||||
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($nonMember);
|
||||
|
||||
$nonMemberResults = TenantResource::getGlobalSearchResults((string) $tenant->name);
|
||||
|
||||
expect($nonMemberResults)->toHaveCount(0);
|
||||
});
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -23,21 +22,3 @@
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('enforces panel boundary semantics between workspace routes and tenant routes', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'boundary-tenant-b',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}")
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$otherTenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
]);
|
||||
|
||||
expect(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->toBe('/admin/tenants/tenant-123/required-permissions');
|
||||
->toBe('/admin/t/tenant-123/required-permissions');
|
||||
});
|
||||
|
||||
it('builds a tenant-scoped required permissions link with filters', function (): void {
|
||||
@ -22,5 +22,5 @@
|
||||
'type' => 'application',
|
||||
]);
|
||||
|
||||
expect($url)->toBe('/admin/tenants/tenant+123/required-permissions?status=all&type=application');
|
||||
expect($url)->toBe('/admin/t/tenant+123/required-permissions?status=all&type=application');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user