Merge remote-tracking branch 'origin/068-workspaces-v2' into feat/999-merge-integration-session-1769990000

# Conflicts:
#	app/Filament/Resources/TenantResource.php
#	app/Filament/Resources/TenantResource/Pages/CreateTenant.php
#	app/Filament/Resources/TenantResource/Pages/ListTenants.php
#	app/Providers/Filament/AdminPanelProvider.php
#	tests/Feature/Filament/TenantSetupTest.php
This commit is contained in:
Ahmed Darrazi 2026-02-01 19:23:28 +01:00
commit cffa4053c8
91 changed files with 4664 additions and 125 deletions

View File

@ -14,6 +14,8 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting) - PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 (068-workspaces-v2)
- PostgreSQL (via Sail) (068-workspaces-v2)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -33,9 +35,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 068-workspaces-v2: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 - 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 - 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@ -50,7 +50,8 @@ ### Tenant Isolation is Non-negotiable
### RBAC & UI Enforcement Standards (RBAC-UX) ### RBAC & UI Enforcement Standards (RBAC-UX)
RBAC Context — Planes, Roles, and Auditability RBAC Context — Planes, Roles, and Auditability
- The platform MUST maintain two strictly separated authorization planes: - The platform MUST maintain strictly separated authorization planes:
- Workspace plane (`/admin/w/{workspace}`): authenticated Entra users (`users`), authorization is workspace-scoped.
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped. - Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped. - Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration. - Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
@ -69,11 +70,11 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- Any missing server-side authorization is a P0 security bug. - Any missing server-side authorization is a P0 security bug.
RBAC-UX-002 — Deny-as-not-found for non-members RBAC-UX-002 — Deny-as-not-found for non-members
- Tenant membership (and plane membership) is an isolation boundary. - Workspace membership and tenant membership (and plane membership) are isolation boundaries.
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST - If the current actor is not a member of the current workspace or tenant (or otherwise not entitled to the relevant scope), the system MUST
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources. respond as 404 (deny-as-not-found) for scope-scoped routes/actions/resources.
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all - This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`),
action endpoints (Livewire calls included). Global Search results, and all action endpoints (Livewire calls included).
RBAC-UX-003 — Capability denial is 403 (after membership is established) RBAC-UX-003 — Capability denial is 403 (after membership is established)
- Within an established tenant scope, missing permissions are authorization failures. - Within an established tenant scope, missing permissions are authorization failures.
@ -174,4 +175,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28 **Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-31

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
trait ScopesGlobalSearchToWorkspace
{
/**
* The Eloquent relationship name used to scope records to the current workspace.
*/
protected static string $globalSearchWorkspaceRelationship = 'workspace';
public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();
if ($panel?->hasTenancy()) {
$query->withoutGlobalScope($panel->getTenancyScopeName());
}
}
$user = auth()->user();
if (! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->currentWorkspace();
if (! $workspace instanceof Workspace) {
return $query->whereRaw('1 = 0');
}
if (! $context->isMember($user, $workspace)) {
return $query->whereRaw('1 = 0');
}
return $query->whereBelongsTo($workspace, static::$globalSearchWorkspaceRelationship);
}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ChooseWorkspace extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-workspace';
protected static ?string $title = 'Choose workspace';
protected string $view = 'filament.pages.choose-workspace';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @return Collection<int, Workspace>
*/
public function getWorkspaces(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Workspace::query()->whereRaw('1 = 0')->get();
}
return Workspace::query()
->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->orderBy('name')
->get();
}
public function selectWorkspace(int $workspaceId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect('/admin/tenants');
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect('/admin/tenants');
}
}

View File

@ -7,9 +7,11 @@
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
@ -55,22 +57,16 @@ public static function canView(): bool
return false; return false;
} }
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); $workspace = static::resolveCurrentWorkspaceFor($user);
if ($tenantIds->isEmpty()) { if (! $workspace instanceof Workspace) {
return false; return false;
} }
/** @var CapabilityResolver $resolver */ /** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class); $resolver = app(WorkspaceCapabilityResolver::class);
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_CREATE)) {
return true;
}
}
return false;
} }
public function form(Schema $schema): Schema public function form(Schema $schema): Schema
@ -125,10 +121,22 @@ public function create(AuditLogger $auditLogger): void
$data = $this->form->getState(); $data = $this->form->getState();
$tenant = Tenant::query()->create($data);
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = static::resolveCurrentWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
abort(403);
}
$data['workspace_id'] = (int) $workspace->getKey();
$tenant = Tenant::query()->create($data);
if ($user instanceof User) { if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => [ $tenant->getKey() => [
@ -172,8 +180,22 @@ private static function abortIfNonMember(): void
abort(403); abort(403);
} }
if (! $user->tenantMemberships()->exists()) { if (! static::resolveCurrentWorkspaceFor($user) instanceof Workspace) {
abort(404); abort(404);
} }
} }
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
{
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, request());
if (! $workspace instanceof Workspace) {
return null;
}
return $context->isMember($user, $workspace) ? $workspace : null;
}
} }

View File

@ -4,6 +4,13 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
class NoAccess extends Page class NoAccess extends Page
@ -19,4 +26,60 @@ class NoAccess extends Page
protected static ?string $title = 'No access'; protected static ?string $title = 'No access';
protected string $view = 'filament.pages.no-access'; protected string $view = 'filament.pages.no-access';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect('/admin/tenants');
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToWorkspace;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers; use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
@ -9,8 +10,10 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
@ -30,6 +33,7 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -53,6 +57,8 @@
class TenantResource extends Resource class TenantResource extends Resource
{ {
use ScopesGlobalSearchToWorkspace;
// ... [Properties Omitted for Brevity] ... // ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class; protected static ?string $model = Tenant::class;
@ -72,7 +78,7 @@ public static function canCreate(): bool
return false; return false;
} }
return static::userCanManageAnyTenant($user); return static::userCanManageTenantsInCurrentWorkspace($user);
} }
public static function canEdit(Model $record): bool public static function canEdit(Model $record): bool
@ -83,11 +89,12 @@ public static function canEdit(Model $record): bool
return false; return false;
} }
/** @var CapabilityResolver $resolver */ $workspace = static::resolveCurrentWorkspaceFor($user);
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_MANAGE); && $workspace instanceof Workspace
&& (int) $record->workspace_id === (int) $workspace->getKey()
&& static::userCanManageTenantsInCurrentWorkspace($user);
} }
public static function canDelete(Model $record): bool public static function canDelete(Model $record): bool
@ -98,11 +105,12 @@ public static function canDelete(Model $record): bool
return false; return false;
} }
/** @var CapabilityResolver $resolver */ $workspace = static::resolveCurrentWorkspaceFor($user);
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE); && $workspace instanceof Workspace
&& (int) $record->workspace_id === (int) $workspace->getKey()
&& static::userCanDeleteTenantsInCurrentWorkspace($user);
} }
public static function canDeleteAny(): bool public static function canDeleteAny(): bool
@ -113,21 +121,49 @@ public static function canDeleteAny(): bool
return false; return false;
} }
return static::userCanDeleteAnyTenant($user); return static::userCanDeleteTenantsInCurrentWorkspace($user);
} }
private static function userCanManageAnyTenant(User $user): bool private static function userCanDeleteTenantsInCurrentWorkspace(User $user): bool
{ {
return $user->tenantMemberships() $workspace = static::resolveCurrentWorkspaceFor($user);
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGED_TENANTS_CREATE)); if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
} }
private static function userCanDeleteAnyTenant(User $user): bool private static function userCanManageTenantsInCurrentWorkspace(User $user): bool
{ {
return $user->tenantMemberships() $workspace = static::resolveCurrentWorkspaceFor($user);
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)); if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
{
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
return null;
}
return $context->isMember($user, $workspace) ? $workspace : null;
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -174,20 +210,21 @@ public static function form(Schema $schema): Schema
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
// ... [Query Omitted - No Change] ...
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0'); return parent::getEloquentQuery()->whereRaw('1 = 0');
} }
$tenantIds = $user->tenants() $workspace = static::resolveCurrentWorkspaceFor($user);
->withTrashed()
->pluck('tenants.id'); if (! $workspace instanceof Workspace) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery() return parent::getEloquentQuery()
->withTrashed() ->withTrashed()
->whereIn('id', $tenantIds) ->where('workspace_id', (int) $workspace->getKey())
->withCount('policies') ->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at'); ->withMax('policies as last_policy_sync_at', 'last_synced_at');
} }
@ -880,8 +917,12 @@ public static function rbacAction(): Actions\Action
->noSearchResultsMessage('No security groups found') ->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'), ->loadingMessage('Searching groups...'),
]) ])
->visible(fn (Tenant $record): bool => $record->isActive()) ->visible(fn (?Tenant $record): bool => (bool) $record?->isActive())
->disabled(function (Tenant $record): bool { ->disabled(function (?Tenant $record): bool {
if ($record === null) {
return true;
}
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {

View File

@ -4,15 +4,34 @@
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord class CreateTenant extends CreateRecord
{ {
protected static string $resource = TenantResource::class; protected static string $resource = TenantResource::class;
public function mount(): void protected function mutateFormDataBeforeCreate(array $data): array
{ {
$this->redirect('/admin/managed-tenants/onboarding'); $user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, request());
if (! $workspace instanceof Workspace) {
abort(403);
}
$data['workspace_id'] = (int) $workspace->getKey();
return $data;
} }
protected function afterCreate(): void protected function afterCreate(): void

View File

@ -30,7 +30,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding') ->url('/admin/managed-tenants/onboarding')
->disabled(fn (): bool => ! TenantResource::canCreate()) ->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to add managed tenants.'),
]; ];
} }
} }

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantResource\Pages;
use Filament\Pages\Page;
class OnboardingManagedTenant extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Onboard managed tenant';
protected string $view = 'filament.pages.onboarding-managed-tenant';
public function mount(): void
{
$this->redirect('/admin/tenants/create');
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkspace extends CreateRecord
{
protected static string $resource = WorkspaceResource::class;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditWorkspace extends EditRecord
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWorkspaces extends ListRecords
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ViewWorkspace extends ViewRecord
{
protected static string $resource = WorkspaceResource::class;
public function mount(int|string $record): void
{
try {
parent::mount($record);
} catch (ModelNotFoundException) {
abort(404);
}
}
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@ -0,0 +1,209 @@
<?php
namespace App\Filament\Resources\Workspaces\RelationManagers;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class MembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
$workspaceRecord = fn () => $this->getOwnerRecord();
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.name')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user.email')
->label(__('Email'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([
//
])
->headerActions([
UiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
try {
$manager->addMember(
workspace: $workspace,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->apply(),
])
->recordActions([
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->changeRole(
workspace: $workspace,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->apply(),
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->removeMember(
workspace: $workspace,
actor: $actor,
membership: $record,
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->destructive()
->apply(),
])
->toolbarActions([])
->bulkActions([]);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WorkspaceForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required(),
TextInput::make('slug'),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\Workspaces\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class WorkspaceInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('name'),
TextEntry::make('slug')
->placeholder('-'),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\Workspaces\Tables;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WorkspacesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
UiEnforcement::forAction(
Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box')
->visible(fn (Workspace $record): bool => empty($record->archived_at))
->action(function (Workspace $record): void {
$record->forceFill(['archived_at' => now()])->save();
Notification::make()
->title('Workspace archived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->destructive()
->tooltip('You do not have permission to archive this workspace.')
->apply(),
UiEnforcement::forAction(
Action::make('unarchive')
->label('Unarchive')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->visible(fn (Workspace $record): bool => ! empty($record->archived_at))
->action(function (Workspace $record): void {
$record->forceFill(['archived_at' => null])->save();
Notification::make()
->title('Workspace unarchived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->tooltip('You do not have permission to unarchive this workspace.')
->apply(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\Pages\CreateWorkspace;
use App\Filament\Resources\Workspaces\Pages\EditWorkspace;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Filament\Resources\Workspaces\RelationManagers\MembershipsRelationManager;
use App\Filament\Resources\Workspaces\Schemas\WorkspaceForm;
use App\Filament\Resources\Workspaces\Schemas\WorkspaceInfolist;
use App\Filament\Resources\Workspaces\Tables\WorkspacesTable;
use App\Models\Workspace;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WorkspaceResource extends Resource
{
protected static ?string $model = Workspace::class;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return WorkspaceForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return WorkspaceInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return WorkspacesTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceIds = $user->newQuery()
->join('workspace_memberships', 'users.id', '=', 'workspace_memberships.user_id')
->where('users.id', $user->getKey())
->pluck('workspace_memberships.workspace_id');
return parent::getEloquentQuery()->whereIn('id', $workspaceIds);
}
public static function getRelations(): array
{
return [
MembershipsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWorkspaces::route('/'),
'create' => CreateWorkspace::route('/create'),
'view' => ViewWorkspace::route('/{record}'),
'edit' => EditWorkspace::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceMember
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
$workspaceParam = $request->route()?->parameter('workspace');
$workspace = $workspaceParam instanceof Workspace
? $workspaceParam
: (is_scalar($workspaceParam)
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
: null);
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
return $next($request);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
if ($workspace !== null) {
return $next($request);
}
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
return new HttpResponse('', 302, ['Location' => $target]);
}
}

View File

@ -21,4 +21,9 @@ public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
} }

View File

@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
@ -170,6 +171,11 @@ public function memberships(): HasMany
return $this->hasMany(TenantMembership::class); return $this->hasMany(TenantMembership::class);
} }
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function roleMappings(): HasMany public function roleMappings(): HasMany
{ {
return $this->hasMany(TenantRoleMapping::class); return $this->hasMany(TenantRoleMapping::class);

43
app/Models/Workspace.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Workspace extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return HasMany<WorkspaceMembership, $this>
*/
public function memberships(): HasMany
{
return $this->hasMany(WorkspaceMembership::class);
}
/**
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'workspace_memberships')
->using(WorkspaceMembership::class)
->withPivot(['id', 'role'])
->withTimestamps();
}
/**
* @return HasMany<Tenant, $this>
*/
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceMembership extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
class WorkspaceMembershipPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, WorkspaceMembership $workspaceMembership): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
public function manageForWorkspace(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
private function isLastOwner(WorkspaceMembership $membership): bool
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return false;
}
$ownerCount = WorkspaceMembership::query()
->where('workspace_id', $membership->workspace_id)
->where('role', WorkspaceRole::Owner->value)
->count();
return $ownerCount <= 1;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
class WorkspacePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Workspace $workspace): bool
{
return false;
}
}

View File

@ -6,8 +6,13 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Policies\ProviderConnectionPolicy; use App\Policies\ProviderConnectionPolicy;
use App\Policies\WorkspaceMembershipPolicy;
use App\Policies\WorkspacePolicy;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -17,21 +22,36 @@ class AuthServiceProvider extends ServiceProvider
{ {
protected $policies = [ protected $policies = [
ProviderConnection::class => ProviderConnectionPolicy::class, ProviderConnection::class => ProviderConnectionPolicy::class,
Workspace::class => WorkspacePolicy::class,
WorkspaceMembership::class => WorkspaceMembershipPolicy::class,
]; ];
public function boot(): void public function boot(): void
{ {
$this->registerPolicies(); $this->registerPolicies();
$resolver = app(CapabilityResolver::class); $tenantResolver = app(CapabilityResolver::class);
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void { $defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool { Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool {
return $resolver->can($user, $tenant, $capability); return $workspaceResolver->can($user, $workspace, $capability);
});
};
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($tenantResolver, $capability): bool {
return $tenantResolver->can($user, $tenant, $capability);
}); });
}; };
foreach (Capabilities::all() as $capability) { foreach (Capabilities::all() as $capability) {
if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) {
$defineWorkspaceCapability($capability);
continue;
}
$defineTenantCapability($capability); $defineTenantCapability($capability);
} }

View File

@ -4,6 +4,7 @@
use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\ManagedTenants\ArchivedStatus; use App\Filament\Pages\ManagedTenants\ArchivedStatus;
use App\Filament\Pages\ManagedTenants\Current; use App\Filament\Pages\ManagedTenants\Current;
use App\Filament\Pages\ManagedTenants\EditManagedTenant; use App\Filament\Pages\ManagedTenants\EditManagedTenant;
@ -14,11 +15,13 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Middleware\EnsureFilamentTenantSelected;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext; use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Middleware\EnsureFilamentTenantSelected;
use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
@ -49,8 +52,18 @@ public function panel(Panel $panel): Panel
->login(Login::class) ->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void { ->authenticatedRoutes(function (Panel $panel): void {
ChooseTenant::registerRoutes($panel); ChooseTenant::registerRoutes($panel);
ChooseWorkspace::registerRoutes($panel);
NoAccess::registerRoutes($panel); NoAccess::registerRoutes($panel);
if ($panel->hasTenantRegistration()) {
$tenantRegistrationPage = $panel->getTenantRegistrationPage();
Route::get($tenantRegistrationPage::getRoutePath($panel), $tenantRegistrationPage)
->middleware($tenantRegistrationPage::getRouteMiddleware($panel))
->withoutMiddleware($tenantRegistrationPage::getWithoutRouteMiddleware($panel))
->name('tenant.registration');
}
ManagedTenantsIndex::registerRoutes($panel); ManagedTenantsIndex::registerRoutes($panel);
Onboarding::registerRoutes($panel); Onboarding::registerRoutes($panel);
Current::registerRoutes($panel); Current::registerRoutes($panel);
@ -94,8 +107,6 @@ public function panel(Panel $panel): Panel
return redirect('/admin/managed-tenants/archived'); return redirect('/admin/managed-tenants/archived');
}); });
Route::redirect('new', '/admin/managed-tenants/onboarding');
}) })
->tenant(Tenant::class, slugAttribute: 'external_id') ->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t') ->tenantRoutePrefix('t')
@ -126,6 +137,24 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class, FilamentInfoWidget::class,
]) ])
->databaseNotifications() ->databaseNotifications()
->userMenuItems([
Action::make('switch-workspace')
->label('Switch workspace')
->icon('heroicon-o-squares-2x2')
->url('/admin/choose-workspace')
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->count() > 1;
})
->sort(0),
])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
@ -142,6 +171,7 @@ public function panel(Panel $panel): Panel
]) ])
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
'ensure-workspace-selected',
]); ]);
if (! app()->runningUnitTests()) { if (! app()->runningUnitTests()) {

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Audit;
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use Carbon\CarbonImmutable;
class WorkspaceAuditLogger
{
public function log(
Workspace $workspace,
string $action,
array $context = [],
?User $actor = null,
string $status = 'success',
?string $resourceType = null,
?string $resourceId = null,
): AuditLog {
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
return AuditLog::create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => $actor?->getKey(),
'actor_email' => $actor?->email,
'actor_name' => $actor?->name,
'action' => $action,
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $metadata + $context,
'recorded_at' => CarbonImmutable::now(),
]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use Illuminate\Support\Facades\Log;
/**
* Workspace Capability Resolver
*
* Resolves user memberships and capabilities for a given workspace.
* Caches results per request to avoid N+1 queries.
*/
class WorkspaceCapabilityResolver
{
private array $resolvedMemberships = [];
private array $loggedDenials = [];
public function getRole(User $user, Workspace $workspace): ?WorkspaceRole
{
$membership = $this->getMembership($user, $workspace);
if ($membership === null) {
return null;
}
return WorkspaceRole::tryFrom($membership['role']);
}
public function can(User $user, Workspace $workspace, string $capability): bool
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException("Unknown capability: {$capability}");
}
$role = $this->getRole($user, $workspace);
if ($role === null) {
$this->logDenial($user, $workspace, $capability);
return false;
}
$allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability);
if (! $allowed) {
$this->logDenial($user, $workspace, $capability);
}
return $allowed;
}
public function isMember(User $user, Workspace $workspace): bool
{
return $this->getMembership($user, $workspace) !== null;
}
public function clearCache(): void
{
$this->resolvedMemberships = [];
}
private function logDenial(User $user, Workspace $workspace, string $capability): void
{
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
if (isset($this->loggedDenials[$key])) {
return;
}
$this->loggedDenials[$key] = true;
Log::warning('rbac.workspace.denied', [
'capability' => $capability,
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $user->getKey(),
]);
}
private function getMembership(User $user, Workspace $workspace): ?array
{
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
if (! isset($this->resolvedMemberships[$cacheKey])) {
$membership = WorkspaceMembership::query()
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->first(['role']);
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
}
return $this->resolvedMemberships[$cacheKey];
}
}

View File

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use DomainException;
use Illuminate\Support\Facades\DB;
class WorkspaceMembershipManager
{
public function __construct(public WorkspaceAuditLogger $auditLogger)
{
}
public function addMember(
Workspace $workspace,
User $actor,
User $member,
string $role,
string $source = 'manual',
): WorkspaceMembership {
$this->assertValidRole($role);
$this->assertActorCanManage($actor, $workspace);
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
$existing = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('user_id', (int) $member->getKey())
->first();
if ($existing) {
if ($existing->role !== $role) {
$fromRole = (string) $existing->role;
$existing->forceFill([
'role' => $role,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'from_role' => $fromRole,
'to_role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
return $existing->refresh();
}
$membership = WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $member->getKey(),
'role' => $role,
]);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipAdd->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership;
});
}
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
{
$this->assertValidRole($newRole);
$this->assertActorCanManage($actor, $workspace);
try {
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$oldRole = (string) $membership->role;
if ($oldRole === $newRole) {
return $membership;
}
$this->guardLastOwnerDemotion($workspace, $membership, $newRole);
$membership->forceFill([
'role' => $newRole,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership->refresh();
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => (string) $membership->role,
'attempted_to_role' => $newRole,
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
throw $exception;
}
}
public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void
{
$this->assertActorCanManage($actor, $workspace);
try {
DB::transaction(function () use ($workspace, $actor, $membership): void {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$this->guardLastOwnerRemoval($workspace, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRemove->value,
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'role' => (string) $membership->role,
'attempted_action' => 'remove',
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
throw $exception;
}
}
private function assertActorCanManage(User $actor, Workspace $workspace): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
throw new DomainException('Forbidden.');
}
}
private function assertValidRole(string $role): void
{
$valid = array_map(
static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value,
WorkspaceRole::cases(),
);
if (! in_array($role, $valid, true)) {
throw new DomainException('Invalid role.');
}
}
private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
if ($newRole === WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.');
}
}
private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot remove the last remaining owner.');
}
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Services\Auth;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
/**
* Workspace Role to Capability Mapping (Single Source of Truth)
*
* This class defines which capabilities each workspace role has.
* All capability strings MUST be references from the Capabilities registry.
*/
class WorkspaceRoleCapabilityMap
{
/**
* @var array<string, array<int, string>>
*/
private static array $roleCapabilities = [
WorkspaceRole::Owner->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MANAGE,
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
],
WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
],
WorkspaceRole::Operator->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
],
WorkspaceRole::Readonly->value => [
Capabilities::WORKSPACE_VIEW,
],
];
/**
* @return array<string>
*/
public static function getCapabilities(WorkspaceRole|string $role): array
{
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
return self::$roleCapabilities[$roleValue] ?? [];
}
/**
* @return array<string>
*/
public static function rolesWithCapability(string $capability): array
{
$roles = [];
foreach (self::$roleCapabilities as $role => $capabilities) {
if (in_array($capability, $capabilities, true)) {
$roles[] = $role;
}
}
return $roles;
}
public static function hasCapability(WorkspaceRole|string $role, string $capability): bool
{
return in_array($capability, self::getCapabilities($role), true);
}
}

View File

@ -16,4 +16,9 @@ enum AuditActionId: string
// Diagnostics / repair actions. // Diagnostics / repair actions.
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged'; case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
case WorkspaceMembershipAdd = 'workspace_membership.add';
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
case WorkspaceMembershipRemove = 'workspace_membership.remove';
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
} }

View File

@ -68,6 +68,18 @@ class Capabilities
// Audit // Audit
public const AUDIT_VIEW = 'audit.view'; public const AUDIT_VIEW = 'audit.view';
// Workspaces
public const WORKSPACE_VIEW = 'workspace.view';
public const WORKSPACE_MANAGE = 'workspace.manage';
public const WORKSPACE_ARCHIVE = 'workspace.archive';
// Workspace memberships
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
/** /**
* Get all capability constants * Get all capability constants
* *

View File

@ -0,0 +1,11 @@
<?php
namespace App\Support\Auth;
enum WorkspaceRole: string
{
case Owner = 'owner';
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
}

View File

@ -6,7 +6,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips; use App\Support\Auth\UiTooltips as AuthUiTooltips;
use Closure; use Closure;
@ -349,10 +352,42 @@ private function applyServerSideGuard(): void
/** /**
* Resolve the current access context with an optional record. * Resolve the current access context with an optional record.
*/ */
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext private function resolveContextWithRecord(?Model $record = null): TenantAccessContext|WorkspaceAccessContext
{ {
$user = auth()->user(); $user = auth()->user();
$workspace = $this->resolveWorkspaceWithRecord($record);
if ($workspace instanceof Workspace) {
if (! $user instanceof User) {
return new WorkspaceAccessContext(
user: null,
workspace: null,
isMember: false,
hasCapability: false,
);
}
$isMember = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('user_id', (int) $user->getKey())
->exists();
$hasCapability = true;
if ($this->capability !== null && $isMember) {
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
$hasCapability = $resolver->can($user, $workspace, $this->capability);
}
return new WorkspaceAccessContext(
user: $user,
workspace: $workspace,
isMember: $isMember,
hasCapability: $hasCapability,
);
}
// For table actions, resolve the record and use it as tenant if it's a Tenant // For table actions, resolve the record and use it as tenant if it's a Tenant
$tenant = $this->resolveTenantWithRecord($record); $tenant = $this->resolveTenantWithRecord($record);
@ -383,6 +418,33 @@ private function resolveContextWithRecord(?Model $record = null): TenantAccessCo
); );
} }
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
{
if ($record instanceof Workspace) {
return $record;
}
if ($record instanceof WorkspaceMembership) {
return $record->workspace;
}
if ($this->record !== null) {
$resolved = $this->record instanceof Closure
? ($this->record)()
: $this->record;
if ($resolved instanceof Workspace) {
return $resolved;
}
if ($resolved instanceof WorkspaceMembership) {
return $resolved->workspace;
}
}
return null;
}
/** /**
* Resolve the tenant for this action with an optional record. * Resolve the tenant for this action with an optional record.
* *

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
/**
* DTO representing the access context for a workspace-scoped UI action.
*/
final readonly class WorkspaceAccessContext
{
public function __construct(
public ?User $user,
public ?Workspace $workspace,
public bool $isMember,
public bool $hasCapability,
) {}
/**
* Non-members should receive 404 (deny-as-not-found).
*/
public function shouldDenyAsNotFound(): bool
{
return ! $this->isMember;
}
/**
* Members without capability should receive 403 (forbidden).
*/
public function shouldDenyAsForbidden(): bool
{
return $this->isMember && ! $this->hasCapability;
}
/**
* User is authorized to perform the action.
*/
public function isAuthorized(): bool
{
return $this->isMember && $this->hasCapability;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Support\Workspaces;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Http\Request;
final class WorkspaceContext
{
public const SESSION_KEY = 'current_workspace_id';
public function __construct(private WorkspaceResolver $resolver)
{
}
public function currentWorkspaceId(?Request $request = null): ?int
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$id = $session->get(self::SESSION_KEY);
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function currentWorkspace(?Request $request = null): ?Workspace
{
$id = $this->currentWorkspaceId($request);
if (! $id) {
return null;
}
$workspace = Workspace::query()->whereKey($id)->first();
if (! $workspace) {
return null;
}
if (! $this->isWorkspaceSelectable($workspace)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
if ($user !== null) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->forget(self::SESSION_KEY);
if ($user !== null && $user->last_workspace_id !== null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$currentId = $this->currentWorkspaceId($request);
if ($currentId) {
$current = Workspace::query()->whereKey($currentId)->first();
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
$session->forget(self::SESSION_KEY);
if ((int) $user->last_workspace_id === (int) $currentId) {
$user->forceFill(['last_workspace_id' => null])->save();
}
} else {
return $current;
}
}
if ($user->last_workspace_id !== null) {
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
$user->forceFill(['last_workspace_id' => null])->save();
} else {
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
return $workspace;
}
}
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->with('workspace')
->get();
$selectableWorkspaces = $memberships
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
->values();
if ($selectableWorkspaces->count() === 1) {
/** @var Workspace $workspace */
$workspace = $selectableWorkspaces->first();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
return $workspace;
}
return null;
}
public function isMember(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Workspaces;
use App\Models\Workspace;
final class WorkspaceResolver
{
public function resolve(string $value): ?Workspace
{
$workspace = Workspace::query()
->where('slug', $value)
->first();
if ($workspace !== null) {
return $workspace;
}
if (! ctype_digit($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->first();
}
}

View File

@ -14,6 +14,8 @@
$middleware->alias([ $middleware->alias([
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
]); ]);
$middleware->prependToPriorityList( $middleware->prependToPriorityList(

View File

@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -17,6 +18,7 @@ class TenantFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'name' => fake()->company(), 'name' => fake()->company(),
'external_id' => fake()->uuid(), 'external_id' => fake()->uuid(),
'tenant_id' => fake()->uuid(), 'tenant_id' => fake()->uuid(),

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workspace>
*/
class WorkspaceFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->company();
return [
'name' => $name,
'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5),
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceMembership>
*/
class WorkspaceMembershipFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => \App\Models\Workspace::factory(),
'user_id' => \App\Models\User::factory(),
'role' => 'operator',
];
}
}

View File

@ -52,6 +52,12 @@ public function up(): void
->where('tenant_id', 'local-tenant') ->where('tenant_id', 'local-tenant')
->update(['status' => 'archived', 'is_current' => false]); ->update(['status' => 'archived', 'is_current' => false]);
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
return;
}
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL'); DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL');
} }

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspaces', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->nullable()->unique();
$table->timestamps();
$table->index('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_memberships', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);
$table->index(['workspace_id', 'role']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_memberships');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('last_workspace_id')
->nullable()
->after('remember_token')
->constrained('workspaces')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('last_workspace_id');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('workspace_id')
->nullable()
->after('id')
->constrained('workspaces')
->nullOnDelete();
$table->index('workspace_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
});
}
};

View File

@ -0,0 +1,164 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_old');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'workspace_id' => null,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_old');
Schema::enableForeignKeyConstraints();
return;
}
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL');
Schema::table('audit_logs', function (Blueprint $table) {
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id');
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_new');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
'audit_logs_workspace_id_action_index',
'audit_logs_workspace_id_resource_type_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_new');
Schema::enableForeignKeyConstraints();
return;
}
Schema::table('audit_logs', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
$table->dropIndex(['workspace_id', 'action']);
$table->dropIndex(['workspace_id', 'resource_type']);
});
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL');
}
};

View File

@ -0,0 +1,145 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('workspaces')) {
return;
}
$now = now();
$defaultWorkspaceId = DB::table('workspaces')
->where('slug', 'default')
->value('id');
if (! $defaultWorkspaceId) {
$defaultWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Default Workspace',
'slug' => 'default',
'created_at' => $now,
'updated_at' => $now,
]);
}
if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) {
DB::table('tenants')
->whereNull('workspace_id')
->update([
'workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
if (! Schema::hasTable('workspace_memberships')) {
return;
}
$roleRankToRole = [
4 => 'owner',
3 => 'manager',
2 => 'operator',
1 => 'readonly',
];
$userRoleRanks = collect();
if (Schema::hasTable('tenant_memberships')) {
$userRoleRanks = DB::table('tenant_memberships')
->select([
'user_id',
DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"),
])
->groupBy('user_id')
->get();
}
$rows = [];
$userIds = [];
foreach ($userRoleRanks as $row) {
$role = $roleRankToRole[(int) $row->role_rank] ?? null;
if (! $role) {
continue;
}
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $row->user_id,
'role' => $role,
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $row->user_id;
}
if (empty($rows) && Schema::hasTable('users')) {
$firstUserId = DB::table('users')->orderBy('id')->value('id');
if ($firstUserId) {
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $firstUserId,
'role' => 'owner',
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $firstUserId;
}
}
if (! empty($rows)) {
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('workspace_memberships')->insertOrIgnore($chunk);
}
}
$ownerCount = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->where('role', 'owner')
->count();
if ($ownerCount === 0) {
$firstMembershipId = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->orderBy('id')
->value('id');
if ($firstMembershipId) {
DB::table('workspace_memberships')
->where('id', $firstMembershipId)
->update([
'role' => 'owner',
'updated_at' => $now,
]);
}
}
if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) {
DB::table('users')
->whereIn('id', array_unique($userIds))
->whereNull('last_workspace_id')
->update([
'last_workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
return;
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('slug');
$table->index('archived_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropIndex(['archived_at']);
$table->dropColumn('archived_at');
});
}
};

View File

@ -0,0 +1,38 @@
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a workspace to continue.
</div>
@php
$workspaces = $this->getWorkspaces();
@endphp
@if ($workspaces->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No active workspaces are available for your account.
You can create one using the button above.
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($workspaces as $workspace)
<div wire:key="workspace-{{ $workspace->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspace->name }}
</div>
<x-filament::button
type="button"
color="primary"
wire:click="selectWorkspace({{ (int) $workspace->id }})"
>
Continue
</x-filament::button>
</div>
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>

View File

@ -1,11 +1,12 @@
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
You dont have access to any tenants yet. You dont have access to any workspaces yet.
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Ask an administrator to add you to a tenant, then sign in again. Ask an administrator to add you to a workspace.
If you are setting up a new account, you can also create a workspace using the button above.
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -0,0 +1,5 @@
<x-filament-panels::page>
<div class="text-sm text-gray-600 dark:text-gray-300">
Redirecting…
</div>
</x-filament-panels::page>

View File

@ -4,7 +4,17 @@
use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\TenantOnboardingController; use App\Http\Controllers\TenantOnboardingController;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\OnboardingManagedTenant;
use App\Models\Workspace;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('welcome');
@ -16,6 +26,24 @@
Route::get('/admin/consent/start', TenantOnboardingController::class) Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start'); ->name('admin.consent.start');
// Fallback route: Filament's layout generates this URL when tenancy registration is enabled.
// In this app, package route registration may not always define it early enough, which breaks
// rendering on tenant-scoped routes.
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->prefix('/admin')
->name('filament.admin.')
->get('/register-tenant', RegisterTenant::class)
->name('tenant.registration');
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
->name('admin.rbac.start'); ->name('admin.rbac.start');
@ -28,3 +56,52 @@
Route::get('/auth/entra/callback', [EntraController::class, 'callback']) Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/new', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return redirect('/admin/choose-workspace');
}
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
})
->name('admin.legacy.onboarding');
Route::bind('workspace', function (string $value): Workspace {
/** @var WorkspaceResolver $resolver */
$resolver = app(WorkspaceResolver::class);
$workspace = $resolver->resolve($value);
abort_unless($workspace instanceof Workspace, 404);
return $workspace;
});
Route::middleware(['web', 'auth', 'ensure-workspace-member'])
->prefix('/admin/w/{workspace}')
->group(function (): void {
Route::get('/', fn () => redirect('/admin/tenants'))
->name('admin.workspace.home');
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
Route::get('/managed-tenants', fn () => redirect('/admin/tenants'))
->name('admin.workspace.managed-tenants.index');
Route::get('/managed-tenants/onboarding', OnboardingManagedTenant::class)
->name('admin.workspace.managed-tenants.onboarding');
});
if (app()->runningUnitTests()) {
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/_test/workspace-context', function (Request $request) {
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request);
return response()->json([
'workspace_id' => $workspaceId,
]);
});
}

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Workspace Model, Memberships & Managed Tenants (v2)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-31
**Feature**: [../spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-01-31.
- No unresolved ambiguities detected; assumptions captured under Requirements.

View File

@ -0,0 +1,61 @@
openapi: 3.0.3
info:
title: TenantPilot Admin (Workspace v2)
version: 0.1.0
description: |
High-level contract for workspace-scoped admin entry points.
servers:
- url: /
paths:
/admin/choose-workspace:
get:
summary: Choose workspace page
description: Render a list of workspaces the current user is a member of.
responses:
'200': { description: OK }
'302': { description: Redirect (auto-select when only one membership) }
/admin/no-access:
get:
summary: No access page
description: Neutral page for authenticated users with zero workspace memberships.
responses:
'200': { description: OK }
/admin/w/{workspace}/managed-tenants:
get:
summary: List managed tenants (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema: { type: string }
description: Workspace identifier (slug preferred; id fallback)
responses:
'200': { description: OK }
'404': { description: Not found (non-member) }
/admin/w/{workspace}/managed-tenants/onboarding:
get:
summary: Start onboarding (workspace-scoped)
parameters:
- name: workspace
in: path
required: true
schema: { type: string }
responses:
'200': { description: OK }
'403': { description: Forbidden (member missing capability) }
'404': { description: Not found (non-member) }
components:
securitySchemes:
sessionAuth:
type: apiKey
in: cookie
name: tenantpilot_session
security:
- sessionAuth: []

View File

@ -0,0 +1,81 @@
# Data Model: Workspace Model, Memberships & Managed Tenants (v2)
**Date**: 2026-01-31
**Feature**: [spec.md](spec.md)
## Entities
### Workspace
Represents an organization/customer scope.
**Fields**
- `id` (bigint, PK)
- `name` (string, required)
- `slug` (string, nullable, unique)
- `status` (enum/string; values: `active`, `archived`; default `active`)
- `created_at`, `updated_at`
**Relationships**
- hasMany `WorkspaceMembership`
- hasMany `ManagedTenant`
**Validation / invariants**
- `name` required and non-empty
- `slug` unique when present
- archived workspaces cannot be selected as current workspace
---
### WorkspaceMembership
Associates a user to a workspace with one role.
**Fields**
- `id` (bigint, PK)
- `workspace_id` (FK workspaces)
- `user_id` (FK users)
- `role` (enum/string; values: `owner`, `manager`, `operator`, `readonly`)
- `created_at`, `updated_at`
**Constraints**
- Unique(`workspace_id`, `user_id`)
**Invariants**
- Last-owner guard: there must always be at least one `owner` membership per workspace.
---
### ManagedTenant
A Microsoft Entra/Intune tenant managed inside exactly one Workspace.
**Fields (v2 additions)**
- `workspace_id` (FK workspaces)
- `entra_tenant_id` (string)
**Constraints**
- Unique(`entra_tenant_id`) globally
- Index(`workspace_id`)
---
### User
Existing identity row.
**Fields (v2 additions)**
- `last_workspace_id` (nullable FK workspaces)
**Relationships**
- hasMany `WorkspaceMembership`
## State transitions
- Workspace `status`: `active``archived` (archived should not be selectable as current workspace)
- Membership role changes: `owner|manager|operator|readonly` with server-side last-owner guard
## Notes
- URL identity uses `slug` when present, else `id`.
- Current workspace context is session-backed with a DB fallback via `users.last_workspace_id`.

View File

@ -0,0 +1,192 @@
# Implementation Plan: Workspace Model, Memberships & Managed Tenants (v2)
**Branch**: `068-workspaces-v2` | **Date**: 2026-01-31 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce a Workspace hierarchy as the primary scope for the tenant-plane admin panel:
- Workspace-scoped routing (`/admin/w/{workspace}/...`) with deny-as-not-found (404 semantics) for non-members.
- Workspace memberships (owner/manager/operator/readonly) as the source of truth for access and capability derivation.
- Workspace switcher + choose-workspace/no-access entry flows with session + `users.last_workspace_id` persistence.
- Managed Tenants become children of Workspaces; onboarding entry becomes canonical under Workspace scope.
- Global search becomes workspace-safe (results only within current workspace; no leakage).
If the repository also contains tenant-scoped routes (e.g. `/admin/t/{tenant}/...`), they must be made workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found).
Design decisions and rationale are captured in [research.md](research.md).
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4
**Storage**: PostgreSQL (via Sail)
**Testing**: Pest v4 (+ PHPUnit v12 underneath)
**Target Platform**: Web application; local dev in Laravel Sail; deploy via containers (Dokploy)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: Admin UX: workspace-scoped pages should remain responsive (target < 200ms p95 for DB-only requests on typical datasets).
**Constraints**:
- Sail-first execution for all PHP/Artisan/Composer/Node commands.
- No full provider refactor; keep changes scoped to Workspace model, routing/scope, memberships, and managed tenant scoping.
- Preserve deny-as-not-found semantics for non-members; do not leak via global search.
**Scale/Scope**: Multi-workspace, multi-tenant-plane: 1 user may belong to many workspaces; each workspace may contain multiple managed tenants.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- 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
**Gate evaluation (pre-Phase 0): PASS (with notes)**
- Inventory-first: Not directly affected (workspace + membership + routing).
- Read/write separation: Workspace/membership mutations are DB-only; require confirmation for destructive-like actions (remove member / demote last owner attempt), emit audit entries, and add tests.
- Graph contract path: No new Graph calls introduced.
- Deterministic capabilities: Extend the canonical capability registry with workspace-plane capabilities; implement deterministic role → capability mapping and test it.
- RBAC-UX: Apply the same semantics with Workspace scope:
- Non-member workspace scope → deny-as-not-found.
- Member missing capability → forbidden.
- UI disabled vs hidden rules remain.
- Global search safety: Introduce workspace-scoped global search query behavior (no results outside current workspace).
- Run observability: Not applicable (no long-running operations planned). Membership changes still require audit logs.
- Badge semantics: Not expected to change for this feature.
## 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)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ ├── Resources/
│ │ ├── WorkspaceResource.php
│ │ └── ...
│ └── Pages/
│ ├── ChooseWorkspace.php
│ └── NoAccess.php
├── Http/
│ └── Middleware/
│ ├── EnsureWorkspaceSelected.php
│ └── EnsureWorkspaceMember.php
├── Models/
│ ├── Workspace.php
│ ├── WorkspaceMembership.php
│ └── Tenant.php (existing; updated to belong to Workspace)
├── Policies/
│ ├── WorkspacePolicy.php
│ └── WorkspaceMembershipPolicy.php
├── Support/
│ ├── Auth/
│ │ ├── Capabilities.php (extend with workspace-plane capabilities)
│ │ └── WorkspaceRole.php (new enum)
│ └── Rbac/
│ ├── UiEnforcement.php (extend or add workspace variant)
│ └── ...
└── Services/
└── Auth/
├── RoleCapabilityMap.php (tenant-plane)
└── WorkspaceRoleCapabilityMap.php (new; workspace-plane)
database/migrations/
tests/Feature/
routes/web.php
bootstrap/app.php (middleware registration; Laravel 12)
bootstrap/providers.php (panel providers; Laravel 11+ rule)
```
**Structure Decision**: Laravel monolith. Implement Workspace scope via middleware + Filament pages/resources under the existing admin panel.
## Phase 0 — Outline & Research (output: research.md)
Completed in [research.md](research.md). Key outcomes:
- URL identity: slug preferred, id fallback.
- Current workspace persistence: session + `users.last_workspace_id`.
- Managed Tenant uniqueness: global unique `entra_tenant_id`.
- Zero memberships: neutral `/admin/no-access` page.
- Workspace-scoped routing + middleware enforcement.
- Global search scoped to active workspace.
- Workspace-level audit stream for membership mutations, implemented as `AuditLog`-compatible entries (stable action IDs; redacted).
## Phase 1 — Design & Contracts
### Data model
See [data-model.md](data-model.md) for entities, constraints, and invariants.
### Contracts
High-level entry-point contract is captured in [contracts/admin-workspaces.openapi.yaml](contracts/admin-workspaces.openapi.yaml).
### Quickstart
Manual verification guidance is in [quickstart.md](quickstart.md).
### Post-design Constitution Re-check
Expected PASS: this feature is DB-only and authorization-heavy; ensure:
- deny-as-not-found for non-members is enforced at middleware + query levels,
- member-without-capability is 403,
- deterministic capability mapping tests exist,
- membership mutations are audited.
## Phase 2 — Implementation Task Preview (for /speckit.tasks)
Planned task groupings:
1) Database migrations + models (workspaces, memberships, user last_workspace_id, tenant workspace_id)
2) Workspace context + middleware + routing (choose-workspace, no-access, `/admin/w/{workspace}` prefix)
3) RBAC/capabilities (capability registry additions + role maps + policies)
4) Filament resources/pages (Workspace CRUD, Members relation manager, managed tenant onboarding/list scoping)
5) Global search scoping (workspace-safe global search queries)
6) Auditing for membership mutations (AuditLog-compatible)
7) Workspace lifecycle (archive/unarchive + selection invalidation)
8) Deterministic capability mapping tests (golden/snapshot)
9) Tests (Pest): last-owner guard, 404/403 semantics, global search scoping, workspace selection flows, migration verification
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@ -0,0 +1,45 @@
# Quickstart: Workspace Model, Memberships & Managed Tenants (v2)
**Date**: 2026-01-31
**Feature**: [spec.md](spec.md)
## Prereqs
- Start Sail: `./vendor/bin/sail up -d`
## Local setup (after implementation)
- Run migrations: `./vendor/bin/sail artisan migrate`
## Manual verification checklist
1) **Workspace selection**
- Create two workspaces.
- Add the current user to both.
- Verify `/admin/choose-workspace` lists only those workspaces.
- Select one and confirm subsequent navigation stays scoped.
2) **Zero memberships**
- Use a user with no workspace memberships.
- Enter admin panel entry point.
- Verify redirect to `/admin/no-access`.
- Verify the user can create a workspace and becomes an Owner.
3) **Isolation semantics**
- Create Workspace A and Workspace B.
- Ensure user is member only of Workspace A.
- Attempt to open any Workspace B URL.
- Verify deny-as-not-found behavior.
4) **Managed tenant scoping**
- Add a managed tenant inside Workspace A.
- Verify it does not show up in Workspace B.
5) **Membership management + last owner**
- In a workspace with exactly one owner, attempt to remove/demote that owner.
- Verify the action is blocked and audited.
## Test execution (after implementation)
- Run targeted tests: `./vendor/bin/sail artisan test --compact --filter=Workspace`
- Run formatter: `./vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,105 @@
# Research: Workspace Model, Memberships & Managed Tenants (v2)
**Date**: 2026-01-31
**Feature**: [spec.md](spec.md)
This document consolidates the key design decisions needed to implement the spec in this repo.
## Decisions
### 1) Workspace identifier in URLs
**Decision**: Prefer Workspace `slug` in URLs; fall back to numeric `id` when a workspace has no slug.
**Rationale**:
- Human-friendly URLs for admins (`/admin/w/acme/...`).
- Keeps migration pain low because slugs can be introduced incrementally.
**Alternatives considered**:
- Always numeric id: easiest but less readable.
- Slug-only required: clean but increases migration/validation burden.
---
### 2) Persisting current workspace selection
**Decision**: Persist `current_workspace_id` in session AND store a nullable `users.last_workspace_id`.
**Rationale**:
- Session is canonical for the current request.
- DB persistence improves UX across new sessions / devices without requiring the user to re-select every time.
**Alternatives considered**:
- Session only: simpler but annoying across logins.
- URL-only: makes deep links harder and doesnt support “default workspace” semantics.
---
### 3) Managed Tenant uniqueness
**Decision**: Entra tenant id is globally unique (a Managed Tenant belongs to exactly one Workspace).
**Rationale**:
- Avoids ambiguous ownership and accidental double-management of the same Microsoft tenant.
- Aligns with “no tenant-in-tenant” goal.
**Alternatives considered**:
- Unique per workspace: enables duplicates but creates confusing operational ownership.
---
### 4) Zero-membership entry behavior
**Decision**: Show a neutral `/admin/no-access` page for users with 0 memberships (not 404).
**Rationale**:
- Clear UX while still not leaking any workspace existence.
**Alternatives considered**:
- 404: secure but confusing; users think the app is broken.
---
### 5) How to scope the admin panel without Filament tenancy
**Decision**: Make Workspace context a first-class routing concern (`/admin/w/{workspace}/...`) enforced via middleware + session context. Do not use Filament tenancy (`/admin/t/{tenant}`) as the primary structure.
**Rationale**:
- Meets “no tenant-in-tenant” and removes the overloaded “tenant” concept in UI.
- Middleware is the cleanest place to enforce deny-as-not-found for non-members.
**Alternatives considered**:
- Use Filament tenancy to represent workspaces: would keep the same tenancy mechanisms but continues the “tenant-in-tenant” confusion.
---
### 6) Global search scoping
**Decision**: Global search must be scoped to the active Workspace and return no results outside it.
**Rationale**:
- Prevents leakage (no hints) and aligns with constitution RBAC-UX.
- Repo already has a tenant-scoped global search trait; we can introduce a workspace-scoped variant.
**Alternatives considered**:
- Hide search results at render time: insufficient, because global search queries must also be scoped.
---
### 7) Audit logging for workspace membership changes
**Decision**: Introduce a workspace-level audit log stream for membership mutations.
**Rationale**:
- Existing `audit_logs` table is tenant-scoped (requires `tenant_id`) and cannot represent workspace-only changes cleanly.
- Additive schema avoids breaking existing auditing.
**Alternatives considered**:
- Make `audit_logs.tenant_id` nullable and add `workspace_id`: higher migration and code risk.
## Open Questions (implementation-level)
These are not spec ambiguities but implementation choices to resolve while coding:
- Whether to build a generic “scope context” abstraction (tenant/workspace) or implement a workspace-specific parallel to the existing tenant helpers.
- How to progressively migrate existing Filament resources from `/admin/t/{tenant}` to `/admin/w/{workspace}` without breaking deep links (redirect strategy).

View File

@ -0,0 +1,214 @@
# Feature Specification: Workspace Model, Memberships & Managed Tenants (v2)
**Feature Branch**: `068-workspaces-v2`
**Created**: 2026-01-31
**Status**: Draft
**Input**: User description: "Spec 068 v2 — Workspace Model, Memberships & Managed Tenants (admin panel scope; replace tenant-scoped UI with workspace-scoped hierarchy; add workspace memberships/roles, switcher, 404/403 semantics, audit logs, and default workspace migration)"
## Clarifications
### Session 2026-01-31
- Q: What should identify the workspace in URLs (id vs slug)? → A: Prefer `slug` in URLs; if missing, fall back to numeric `id`.
- Q: How should current workspace selection be persisted? → A: Persist in session and also store a nullable `last_workspace_id` on the user.
- Q: Should an Entra tenant id be globally unique or unique per workspace? → A: Global unique `entra_tenant_id` (a Managed Tenant belongs to exactly one Workspace).
- Q: What should users with 0 workspace memberships see? → A: A neutral `/admin/no-access` page (not 404).
## Routing, Scoping & Selection Semantics
### Routing “planes”
This feature introduces **Workspace-scoped** admin routing and treats Workspace membership as a primary isolation boundary.
- **Workspace plane (workspace-scoped)**: `/admin/w/{workspace}/...`
- **Entry points (unscoped)**: `/admin/choose-workspace`, `/admin/no-access`
If the repository also has existing tenant-scoped routes (e.g. `/admin/t/{tenant}/...`), they MUST be made workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found).
### Workspace selection algorithm (deterministic)
When a signed-in user enters the admin panel:
1) If the session has a selected workspace and it is still valid (user is a member, workspace not archived) → use it.
2) Else if `users.last_workspace_id` is set and still valid → select it, store into session, and use it.
3) Else if the user has exactly 1 active membership → auto-select that workspace, store into session + `last_workspace_id`, and use it.
4) Else if the user has 2+ active memberships → route to `/admin/choose-workspace`.
5) Else (0 memberships) → route to `/admin/no-access`.
If a selected workspace becomes invalid (membership removed or workspace archived), selection MUST be cleared and the algorithm re-run.
## 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 - Choose workspace and get correct scope (Priority: P1)
As a signed-in user, I want the admin panel to require an active Workspace context so that everything I see and search is scoped to my current Workspace and cannot leak information about other Workspaces.
**Why this priority**: This is the foundation for correct navigation, isolation, and predictable operations in enterprise / MSP usage.
**Independent Test**: Can be tested by creating 2 workspaces, adding a user to only one, and verifying workspace-scoped navigation + 404 semantics for non-members.
**Acceptance Scenarios**:
1. **Given** a user who is a member of exactly one Workspace, **When** they enter the admin panel entry point, **Then** they are automatically routed into that Workspace context.
2. **Given** a user who is a member of multiple Workspaces, **When** they enter the admin panel entry point, **Then** they are routed to a "choose workspace" page and can select one.
3. **Given** a user who is not a member of Workspace A, **When** they attempt to access any Workspace A scoped URL, **Then** they receive a not-found outcome (deny-as-not-found; no hints).
4. **Given** a user is a member of Workspace A, **When** they switch to Workspace B, **Then** navigation and global search results reflect only data in Workspace B.
---
### User Story 2 - Manage workspace members and roles (Priority: P2)
As a Workspace Owner or Manager, I want to add and manage Workspace members and their roles so that the right people can access the Workspace with the right level of permissions.
**Why this priority**: Multi-user operation is required for MSP/enterprise teams; role-based access is a core control.
**Independent Test**: Can be tested by creating a workspace with 2+ users and validating add/change/remove behavior, including last-owner protections and audit entries.
**Acceptance Scenarios**:
1. **Given** a Workspace with an Owner, **When** the Owner adds an existing user as a member, **Then** the user can access the Workspace within the admin panel.
2. **Given** a Workspace member, **When** an Owner changes that members role, **Then** their effective permissions change accordingly.
3. **Given** a Workspace with exactly one Owner, **When** that Owner is removed or demoted, **Then** the system prevents the action and records that it was blocked.
4. **Given** a user who is a member but lacks a required capability for an action, **When** they try to execute the action, **Then** the action is blocked with a forbidden outcome (member → 403 semantics) and does not mutate data.
---
### User Story 3 - Onboard a managed tenant inside a workspace (Priority: P3)
As a Workspace Owner or Manager, I want to add a Managed Tenant into the current Workspace from a single canonical onboarding entry so that onboarding is consistent now and can later evolve into a wizard without changing the entry point.
**Why this priority**: Managed Tenants are the core “things being managed” and must clearly live under a Workspace.
**Independent Test**: Can be tested by adding a Managed Tenant under Workspace A and verifying it does not appear under Workspace B.
**Acceptance Scenarios**:
1. **Given** an active Workspace context, **When** an authorized member starts "add managed tenant" onboarding, **Then** the resulting Managed Tenant belongs to the current Workspace.
2. **Given** a Managed Tenant in Workspace A, **When** viewing managed tenants in Workspace B, **Then** the tenant is not visible.
3. **Given** legacy onboarding entry points, **When** a user visits them, **Then** they are redirected into the canonical onboarding entry for the appropriate Workspace context.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
- User has zero Workspace memberships (newly provisioned account) → should see a neutral "no access" page without leaking other workspaces.
- Previously-selected Workspace is archived or the users membership was removed → current selection must be invalidated and user returned to choose/no-access safely.
- Two concurrent admins attempt to remove/demote the last owner at the same time → system still guarantees at least one owner.
- Managed Tenant identifier is attempted to be added twice (duplicate Entra tenant id) → the second attempt is blocked without creating an ambiguous partial record.
- Global search term matches records in a Workspace the user is not a member of → results must not hint those records exist.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes access control and tenant-plane data ownership. The implementation MUST include:
- tenant/workspace isolation guarantees,
- safety gates for any write/change behavior (preview/confirmation/audit as applicable),
- run visibility/traceability for any long-running work,
- and automated tests that prove isolation and authorization.
If any security-relevant change intentionally skips run tracking, the implementation MUST still emit audit entries that allow incident review.
**Constitution alignment (RBAC-UX):** This feature introduces Workspace-scoped authorization for the admin panel. The implementation MUST:
- explicitly enforce deny-as-not-found for non-members (no hints),
- enforce forbidden outcomes for members who lack the required capability,
- ensure global search is scoped to the active Workspace and non-member-safe,
- use a centralized capability registry and deterministic role mapping (no ad-hoc string checks),
- require explicit confirmation for destructive-like actions,
- and include at least one positive and one negative authorization test.
**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound requests during login, but this exception MUST NOT be used for monitoring/operations functionality.
**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.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001 (Workspace entity)**: System MUST support a Workspace concept as a top-level container representing an organization/customer context.
- **FR-002 (Workspace lifecycle)**: Authorized users MUST be able to create a Workspace and view Workspace details. Workspaces MUST be markable as active or archived.
- **FR-003 (Membership source of truth)**: System MUST support Workspace membership records that assign a role per (workspace, user). Membership MUST be the sole source of truth for Workspace access.
- **FR-004 (Membership constraints)**: System MUST prevent duplicate memberships for the same (workspace, user) pair.
- **FR-005 (Last owner protection)**: System MUST prevent removing or demoting the last remaining Owner of a Workspace.
- **FR-006 (Membership management)**: Workspace Owners/Managers MUST be able to add existing users as members, change member roles, and remove members.
- **FR-007 (Workspace switcher + persistence)**: System MUST provide a workspace switcher that lists only workspaces where the user is a member, and MUST persist the users current workspace selection for the session.
- **FR-008 (Choose-workspace routing)**: If a user is a member of multiple workspaces, system MUST provide a dedicated choose-workspace page and MUST require an explicit selection before accessing workspace-scoped pages.
- **FR-008a (No-access routing)**: If a user has zero workspace memberships, system MUST route them to a neutral "no access" page and MUST NOT return deny-as-not-found for this entry-point case.
- **FR-009 (Workspace-scoped URLs)**: System MUST require Workspace context for all Workspace-plane admin resources under `/admin/w/{workspace}/...`, except authentication and the choose-workspace/no-access entry points.
- If tenant-scoped routes exist elsewhere (e.g. `/admin/t/{tenant}/...`), they MUST be workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found).
- **FR-010 (Managed tenant belongs to workspace)**: Each Managed Tenant MUST belong to exactly one Workspace.
- **FR-011 (Managed tenant uniqueness)**: A Managed Tenant MUST be uniquely identifiable by its Entra tenant identifier, and the system MUST prevent duplicates.
- **FR-012 (Canonical onboarding entry)**: System MUST provide a single canonical "add managed tenant" entry inside Workspace context; legacy entry points MUST redirect to it without allowing creation in an incorrect scope.
- **FR-013 (Global search safety)**: Global search MUST return results only from the users current Workspace, and MUST not leak existence of records in other Workspaces.
- **FR-014 (404 vs 403 semantics)**: For any Workspace-scoped resource:
- Non-member access MUST be deny-as-not-found.
- Member access without the required capability MUST be forbidden.
- **FR-015 (Capabilities and role mapping)**: System MUST derive effective capabilities from Workspace role deterministically using a centralized role → capability mapping.
- **FR-016 (UI behavior for missing capability)**: For members without required capability, the UI MUST show relevant actions as disabled (where applicable) rather than hidden, and execution MUST still be blocked server-side.
- **FR-017 (Audit logging for membership changes)**: System MUST record audit events for membership add, role change, removal, and last-owner-blocked outcomes.
#### Clarified Authorization Rules
- Workspace creation: any signed-in user MAY create a Workspace; the creator becomes an Owner.
- Workspace membership management: only Workspace Owners/Managers.
#### Assumptions
- Creating a Workspace is allowed for signed-in users in v2; the creator becomes an Owner of the new Workspace.
- Workspace URL identifier prefers human-friendly `slug`, falling back to numeric `id` if a workspace does not have a slug.
- Current workspace selection is persisted in session and also saved as a nullable `last_workspace_id` on the user.
#### Legacy Route Scope (explicit)
For v2, the only guaranteed legacy onboarding entry point that MUST redirect is:
- `/admin/new`
Additional legacy routes may exist in the codebase; they should be enumerated and handled as part of implementation if discovered.
#### Out of Scope (v2)
- Onboarding wizard UI (stepper-based experience)
- Automated mapping from external groups to Workspace membership
- Billing and multi-region concerns
### Key Entities *(include if feature involves data)*
- **Workspace**: Represents an organization/customer scope; includes display name, optional human-friendly key, and an active/archived status.
- **Workspace Membership**: Associates a user to a workspace with one role (Owner/Manager/Operator/Readonly).
- **Managed Tenant**: Represents a Microsoft/Entra/Intune tenant managed inside a Workspace; belongs to exactly one Workspace and is uniquely identified by Entra tenant id.
- **Workspace Context**: The current active Workspace selection that scopes navigation and search.
- **Capability**: A named permission (e.g., workspace management, member management, managed tenant management) derived from Workspace role via centralized mapping.
- **Audit Event**: An immutable record of sensitive Workspace membership changes with minimal, non-secret details.
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001 (Isolation)**: 100% of attempts by non-members to access a Workspace-scoped page result in a not-found outcome.
- **SC-002 (Correct scoping)**: 0 global search results are returned from outside the users current Workspace during acceptance testing.
- **SC-003 (Membership management)**: An Owner can add a member, change role, and remove member in under 2 minutes end-to-end using the admin UI.
- **SC-004 (Last-owner safety)**: 100% of attempts to remove/demote the last Owner are blocked and recorded.
- **SC-005 (Migration safety)**: After migration, 100% of existing Managed Tenants are associated with exactly one Workspace.

View File

@ -0,0 +1,175 @@
# Tasks: Workspace Model, Memberships & Managed Tenants (v2)
**Input**: Design documents from `specs/068-workspaces-v2/`
**Prerequisites**: `plan.md` (required), `spec.md` (user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Required (Pest) because this feature changes runtime behavior and authorization semantics.
## Phase 1: Setup (Shared Infrastructure)
- [X] T001 Ensure Sail services are running for development (docker-compose.yml)
- [X] T002 Verify admin panel provider registration (bootstrap/providers.php)
- [X] T003 [P] Create new feature test folder structure (tests/Feature/Workspaces/)
---
## Phase 2: Foundational (Blocking Prerequisites)
- [X] T004 Create Workspace migrations (database/migrations/*_create_workspaces_table.php)
- [X] T005 Create WorkspaceMembership migrations (includes unique `(workspace_id, user_id)` constraint) (database/migrations/*_create_workspace_memberships_table.php)
- [X] T006 Add `users.last_workspace_id` migration (database/migrations/*_add_last_workspace_id_to_users_table.php)
- [X] T007 Add `tenants.workspace_id` migration + indexes + global unique entra tenant id constraint (workspace_id migration + existing `tenant_id` unique constraint)
- [X] T008 [P] Create `Workspace` model + relationships (app/Models/Workspace.php)
- [X] T009 [P] Create `WorkspaceMembership` model + relationships (app/Models/WorkspaceMembership.php)
- [X] T010 [P] Add `workspace()` relationship on Tenant model (app/Models/Tenant.php)
- [X] T011 Create Workspace role enum (app/Support/Auth/WorkspaceRole.php)
- [X] T012 Extend capability registry with workspace-plane capabilities (app/Support/Auth/Capabilities.php)
- [X] T013 Create workspace role→capability mapping (app/Services/Auth/WorkspaceRoleCapabilityMap.php)
- [X] T014 Implement workspace capability resolver (app/Services/Auth/WorkspaceCapabilityResolver.php)
- [X] T015 [P] Add Workspace + Membership policies for server-side enforcement (app/Policies/WorkspacePolicy.php)
- [X] T016 [P] Add WorkspaceMembership policy + last-owner guard hooks (app/Policies/WorkspaceMembershipPolicy.php)
- [X] T017 Implement workspace-scoped context helper (session + user last_workspace_id) (app/Support/Workspaces/WorkspaceContext.php)
- [X] T018 Implement workspace selection middleware (app/Http/Middleware/EnsureWorkspaceSelected.php)
- [X] T019 Implement workspace membership middleware (deny-as-not-found) (app/Http/Middleware/EnsureWorkspaceMember.php)
- [X] T020 Register middleware in Laravel 12 middleware pipeline (bootstrap/app.php)
- [X] T021 Add workspace resolver helper (slug preferred, id fallback) (app/Support/Workspaces/WorkspaceResolver.php)
**Checkpoint**: Foundation ready (DB + capability system + middleware hooks exist)
---
## Phase 3: User Story 1 — Choose workspace and get correct scope (Priority: P1) 🎯 MVP
**Goal**: Users must always operate within an active Workspace context; all workspace-scoped routes are deny-as-not-found for non-members; global search never leaks records.
**Independent Test**: A user with memberships can select/switch workspaces and only see/search within the current workspace; a non-member gets 404 semantics.
### Tests (US1)
- [X] T022 [P] [US1] Add workspace selection routing tests (covers session vs `last_workspace_id` precedence + invalidation) (tests/Feature/Workspaces/WorkspaceSelectionTest.php)
- [X] T023 [P] [US1] Add non-member isolation tests for workspace-scoped routes (tests/Feature/Workspaces/WorkspaceIsolationTest.php)
- [X] T024 [P] [US1] Add global search scoping tests (tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php)
### Implementation (US1)
- [X] T025 [US1] Create ChooseWorkspace page (admin panel) (app/Filament/Pages/ChooseWorkspace.php)
- [X] T026 [US1] Create NoAccess page (admin panel) (app/Filament/Pages/NoAccess.php)
- [X] T027 [US1] Add routes/entry behavior for `/admin/choose-workspace` + `/admin/no-access` (AdminPanelProvider authenticatedRoutes + EnsureWorkspaceSelected allowlist)
- [X] T028 [US1] Implement workspace switcher (user menu) wiring to WorkspaceContext (app/Providers/Filament/AdminPanelProvider.php)
- [X] T029 [US1] Introduce workspace route group `/admin/w/{workspace}` with required middleware (routes/web.php)
- [X] T030 [US1] Introduce workspace-scoped global search query trait (app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php)
- [X] T031 [US1] Apply workspace global search scoping to workspace-owned resources (app/Filament/Resources/**)
**Checkpoint**: User Story 1 works and is testable standalone.
---
## Phase 4: User Story 2 — Manage workspace members and roles (Priority: P2)
**Goal**: Workspace Owner/Manager can add members, change roles, and remove members; last-owner cannot be removed/demoted; all membership mutations are audited.
**Independent Test**: From Workspace view, manage memberships; verify last-owner guard; verify audit entries.
### Tests (US2)
- [X] T032 [P] [US2] Add last-owner guard tests (tests/Feature/Workspaces/LastOwnerGuardTest.php)
- [X] T033 [P] [US2] Add membership mutation authorization tests (403 vs 404 semantics) (tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php)
- [X] T034 [P] [US2] Add audit logging tests for membership mutations (tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php)
### Implementation (US2)
- [X] T035 [US2] Add `workspace_id` support to AuditLog storage (nullable `tenant_id` where required) + indexes (database/migrations/*_add_workspace_id_to_audit_logs_table.php)
- [X] T036 [US2] Extend AuditLog model with optional `workspace()` relationship (app/Models/AuditLog.php)
- [X] T037 [US2] Implement workspace audit logger service writing AuditLog entries + stable action ids (app/Services/Audit/WorkspaceAuditLogger.php)
- [X] T038 [US2] Create WorkspaceResource CRUD with View page (required for global search) (app/Filament/Resources/Workspaces/WorkspaceResource.php)
- [X] T039 [US2] Add WorkspaceResource pages (List/Create/View/Edit) (app/Filament/Resources/Workspaces/Pages/)
- [X] T040 [US2] Implement Members relation manager on WorkspaceResource (app/Filament/Resources/Workspaces/RelationManagers/MembershipsRelationManager.php)
- [X] T041 [US2] Apply UI enforcement (disabled vs hidden + confirmation) for membership actions (app/Support/Rbac/UiEnforcement.php)
- [X] T042 [US2] Enforce last-owner guard server-side in membership mutations (app/Policies/WorkspaceMembershipPolicy.php)
**Checkpoint**: User Story 2 works and is testable standalone.
---
## Phase 5: User Story 3 — Onboard a managed tenant inside a workspace (Priority: P3)
**Goal**: Managed Tenant CRUD/listing/onboarding is always workspace-scoped and uses the canonical onboarding entry under workspace.
**Independent Test**: Add a managed tenant in Workspace A; it never appears in Workspace B; old entry points redirect.
### Tests (US3)
- [X] T043 [P] [US3] Add managed tenant scoping tests (tests/Feature/Workspaces/ManagedTenantScopingTest.php)
- [X] T044 [P] [US3] Add legacy entry-point redirect tests (tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php)
### Implementation (US3)
- [X] T045 [US3] Update tenant-plane naming/terminology in UI to “Managed Tenant” within workspace context (app/Filament/Resources/TenantResource.php)
- [X] T046 [US3] Scope managed tenant list query to current workspace (app/Filament/Resources/TenantResource/Pages/ListTenants.php)
- [X] T047 [US3] Ensure managed tenant create uses current workspace_id and blocks cross-workspace edits (app/Filament/Resources/TenantResource/Pages/CreateTenant.php)
- [X] T048 [US3] Implement canonical onboarding route under workspace scope (app/Filament/Resources/TenantResource/Pages/OnboardingManagedTenant.php)
- [X] T049 [US3] Redirect legacy `/admin/new` entry to choose-workspace or last_workspace onboarding (routes/web.php)
**Checkpoint**: User Story 3 works and is testable standalone.
---
## Final Phase: Migration, Polish & Cross-Cutting Concerns
- [X] T050 Create migration to backfill Default Workspace + assign existing tenants + bootstrap owner membership (database/migrations/*_backfill_default_workspace_and_memberships.php)
- [X] T051 Add workspace selection invalidation when workspace archived or membership removed (app/Support/Workspaces/WorkspaceContext.php)
- [X] T052 Ensure workspace-scoped navigation labels/IA are consistent (resources/views/** and app/Filament/**)
- [X] T053 Run formatter on touched files using Sail (`bin pint --dirty`) (vendor/bin/sail)
- [X] T054 Run targeted test suite for this feature using Sail (`artisan test --compact --filter=Workspaces`) (vendor/bin/sail)
- [X] T055 Validate manual quickstart checklist remains accurate (specs/068-workspaces-v2/quickstart.md)
---
## Additions (Consistency + Constitution Requirements)
- [X] T056 [P] Add deterministic workspace capability mapping golden tests (tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php)
- [X] T057 [P] Add unique workspace membership constraint test (tests/Feature/Workspaces/WorkspaceMembershipUniquenessTest.php)
- [X] T058 [P] Add unique Entra tenant id constraint test (tests/Feature/Workspaces/ManagedTenantUniquenessTest.php)
- [X] T059 [P] Add migration safety test for backfill (SC-005) (tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php)
- [X] T060 [P] Add workspace lifecycle tests (archive/unarchive + selection invalidation) (tests/Feature/Workspaces/WorkspaceLifecycleTest.php)
- [X] T061 Add `archived_at` (or equivalent) to workspaces table + index (database/migrations/*_add_archived_at_to_workspaces_table.php)
- [X] T062 Add archive/unarchive actions with confirmation + authorization in WorkspaceResource (app/Filament/Resources/WorkspaceResource.php)
- [X] T063 [US1] Add “Create Workspace” action on ChooseWorkspace/NoAccess pages (supports first workspace) (app/Filament/Pages/ChooseWorkspace.php and app/Filament/Pages/NoAccess.php)
- [X] T064 [P] Add test for creating the first workspace from no-access/choose-workspace flows (tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php)
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Final Phase
### User Story Dependencies
- **US1** depends on Foundational (workspace context, middleware, capability registry).
- **US2** depends on Foundational (workspace models + policies) and benefits from US1 routing/context.
- **US3** depends on Foundational (workspace_id on managed tenants) and US1 routing/context.
## Parallel Execution Examples
### US1
- [P] Write tests in tests/Feature/Workspaces/WorkspaceSelectionTest.php, tests/Feature/Workspaces/WorkspaceIsolationTest.php, tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php
- Implement pages in app/Filament/Pages/ChooseWorkspace.php and app/Filament/Pages/NoAccess.php
### US2
- [P] Write tests in tests/Feature/Workspaces/LastOwnerGuardTest.php, tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php, tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php
- Implement audit storage/model updates in app/Models/AuditLog.php + app/Services/Audit/WorkspaceAuditLogger.php
### US3
- [P] Write tests in tests/Feature/Workspaces/ManagedTenantScopingTest.php and tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php
- Update tenant resource pages in app/Filament/Resources/TenantResource/Pages/
## Implementation Strategy
- MVP scope is **US1 only** (workspace selection + scoping + search safety + deny-as-not-found isolation).
- Then implement US2 (memberships + auditing + last-owner guard).
- Then implement US3 (managed tenant scoping + canonical onboarding + redirects).

View File

@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -16,6 +18,10 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$this->get('/admin/no-access')->assertOk(); $this->get('/admin/no-access')->assertOk();
$this->get('/admin/choose-tenant')->assertOk(); $this->get('/admin/choose-tenant')->assertOk();
}); });

View File

@ -14,5 +14,5 @@
$response = $this->get('/admin/no-access'); $response = $this->get('/admin/no-access');
$response->assertOk(); $response->assertOk();
$response->assertSee('You dont have access to any tenants yet.'); $response->assertSee('You dont have access to any workspaces yet.');
}); });

View File

@ -398,19 +398,29 @@
}); });
test('tenant can be archived and hidden from default lists', function () { test('tenant can be archived and hidden from default lists', function () {
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-4', 'tenant_id' => 'tenant-4',
'name' => 'Tenant 4', 'name' => 'Tenant 4',
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class) Livewire::actingAs($user)
->test(ListTenants::class)
->callTableAction('archive', $tenant); ->callTableAction('archive', $tenant);
expect(Tenant::count())->toBe(0); expect(Tenant::count())->toBe(0);
@ -436,12 +446,16 @@
}); });
test('tenant table archive filter toggles active and archived tenants', function () { test('tenant table archive filter toggles active and archived tenants', function () {
$active = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$active = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-active', 'tenant_id' => 'tenant-active',
'name' => 'Active Tenant', 'name' => 'Active Tenant',
]); ]);
$archived = Tenant::create([ $archived = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-archived', 'tenant_id' => 'tenant-archived',
'name' => 'Archived Tenant', 'name' => 'Archived Tenant',
]); ]);
@ -450,13 +464,20 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$active->getKey() => ['role' => 'owner'], $active->getKey() => ['role' => 'owner'],
$archived->getKey() => ['role' => 'owner'], $archived->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($active, true); Filament::setTenant($active, true);
$component = Livewire::test(ListTenants::class) $component = Livewire::actingAs($user)
->test(ListTenants::class)
->assertSee($active->name) ->assertSee($active->name)
->assertSee($archived->name); ->assertSee($archived->name);
@ -472,27 +493,38 @@
}); });
test('archived tenant can be restored from the table', function () { test('archived tenant can be restored from the table', function () {
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-restore', 'tenant_id' => 'tenant-restore',
'name' => 'Restore Tenant', 'name' => 'Restore Tenant',
]); ]);
$tenant->delete(); $tenant->delete();
$contextTenant = Tenant::create([ $contextTenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-restore-context', 'tenant_id' => 'tenant-restore-context',
'name' => 'Restore Context Tenant', 'name' => 'Restore Context Tenant',
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
$contextTenant->getKey() => ['role' => 'owner'], $contextTenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($contextTenant, true); Filament::setTenant($contextTenant, true);
Livewire::test(ListTenants::class) Livewire::actingAs($user)
->test(ListTenants::class)
->set('tableFilters.trashed.value', 1) ->set('tableFilters.trashed.value', 1)
->callTableAction('restore', $tenant); ->callTableAction('restore', $tenant);

View File

@ -28,16 +28,11 @@
}); });
test('tenant portfolio tenant view returns 404 for non-member tenant record', function () { test('tenant portfolio tenant view returns 404 for non-member tenant record', function () {
$user = User::factory()->create(); [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']), role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']);
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
$this->get(route('filament.admin.resources.tenants.view', array_merge( $this->get(route('filament.admin.resources.tenants.view', array_merge(
filamentTenantRouteParams($unauthorizedTenant), filamentTenantRouteParams($unauthorizedTenant),
['record' => $unauthorizedTenant], ['record' => $unauthorizedTenant],
@ -45,16 +40,11 @@
}); });
test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () { test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () {
$user = User::factory()->create(); [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']), role: 'owner');
$this->actingAs($user); $this->actingAs($user);
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']);
$unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
$this->get(route('filament.admin.resources.tenants.edit', array_merge( $this->get(route('filament.admin.resources.tenants.edit', array_merge(
filamentTenantRouteParams($unauthorizedTenant), filamentTenantRouteParams($unauthorizedTenant),
['record' => $unauthorizedTenant], ['record' => $unauthorizedTenant],
@ -62,23 +52,17 @@
}); });
test('tenant portfolio lists only tenants the user can access', function () { test('tenant portfolio lists only tenants the user can access', function () {
$user = User::factory()->create(); [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create([
$this->actingAs($user);
$authorizedTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-portfolio-authorized', 'tenant_id' => 'tenant-portfolio-authorized',
'name' => 'Authorized Tenant', 'name' => 'Authorized Tenant',
]); ]), role: 'owner');
$this->actingAs($user);
$unauthorizedTenant = Tenant::factory()->create([ $unauthorizedTenant = Tenant::factory()->create([
'tenant_id' => 'tenant-portfolio-unauthorized', 'tenant_id' => 'tenant-portfolio-unauthorized',
'name' => 'Unauthorized Tenant', 'name' => 'Unauthorized Tenant',
]); ]);
$user->tenants()->syncWithoutDetaching([
$authorizedTenant->getKey() => ['role' => 'owner'],
]);
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
->assertOk() ->assertOk()
->assertSee($authorizedTenant->name) ->assertSee($authorizedTenant->name)
@ -88,11 +72,19 @@
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () { test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
Bus::fake(); Bus::fake();
$workspace = \App\Models\Workspace::factory()->create();
$tenantA = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-a']);
$tenantB = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-b']);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); 'workspace_id' => $workspace->getKey(),
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']); 'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'], $tenantA->getKey() => ['role' => 'owner'],
@ -101,7 +93,8 @@
Filament::setTenant($tenantA, true); Filament::setTenant($tenantA, true);
Livewire::test(ListTenants::class) Livewire::actingAs($user)
->test(ListTenants::class)
->assertTableBulkActionVisible('syncSelected') ->assertTableBulkActionVisible('syncSelected')
->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); ->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB]));
@ -118,10 +111,17 @@
test('tenant portfolio bulk sync is disabled for readonly users', function () { test('tenant portfolio bulk sync is disabled for readonly users', function () {
Bus::fake(); Bus::fake();
$workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-readonly']);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']); 'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'readonly'], $tenant->getKey() => ['role' => 'readonly'],
@ -146,11 +146,18 @@
test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () { test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () {
Bus::fake(); Bus::fake();
$workspace = \App\Models\Workspace::factory()->create();
$tenantA = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-mixed-a']);
$tenantB = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-mixed-b']);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
\App\Models\WorkspaceMembership::factory()->create([
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']); 'workspace_id' => $workspace->getKey(),
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']); 'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'], $tenantA->getKey() => ['role' => 'owner'],

View File

@ -4,6 +4,8 @@
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -20,7 +22,10 @@
function tenantWithApp(): Tenant function tenantWithApp(): Tenant
{ {
return Tenant::create([ $workspace = Workspace::factory()->create();
return Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-guid', 'tenant_id' => 'tenant-guid',
'name' => 'Tenant One', 'name' => 'Tenant One',
'app_client_id' => 'client-123', 'app_client_id' => 'client-123',
@ -29,16 +34,29 @@ function tenantWithApp(): Tenant
]); ]);
} }
function prepareWorkspaceContextFor(User $user, Tenant $tenant): void
{
WorkspaceMembership::factory()->create([
'workspace_id' => $tenant->workspace_id,
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $tenant->workspace_id])->save();
}
test('rbac action prompts login when no delegated token', function () { test('rbac action prompts login when no delegated token', function () {
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'role_definition_id' => 'role-1', 'role_definition_id' => 'role-1',
@ -56,6 +74,7 @@ function tenantWithApp(): Tenant
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -137,7 +156,8 @@ public function request(string $method, string $path, array $options = []): Grap
}; };
}); });
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'role_definition_id' => 'role-1', 'role_definition_id' => 'role-1',
@ -164,6 +184,7 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -253,7 +274,8 @@ public function request(string $method, string $path, array $options = []): Grap
}; };
}); });
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'role_definition_id' => 'role-1', 'role_definition_id' => 'role-1',
@ -278,6 +300,7 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -360,7 +383,8 @@ public function request(string $method, string $path, array $options = []): Grap
}; };
}); });
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'role_definition_id' => 'role-1', 'role_definition_id' => 'role-1',
@ -382,12 +406,14 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'group_mode' => 'existing', 'group_mode' => 'existing',
@ -401,12 +427,14 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'group_mode' => 'existing', 'group_mode' => 'existing',
@ -419,6 +447,7 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -510,7 +539,8 @@ public function request(string $method, string $path, array $options = []): Grap
expect($options)->toHaveKey('group-123'); expect($options)->toHaveKey('group-123');
expect($options['group-123'])->toContain('Ops Team'); expect($options['group-123'])->toContain('Ops Team');
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'group_mode' => 'existing', 'group_mode' => 'existing',
@ -534,6 +564,7 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = tenantWithApp(); $tenant = tenantWithApp();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
prepareWorkspaceContextFor($user, $tenant);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -627,7 +658,8 @@ public function request(string $method, string $path, array $options = []): Grap
expect($roles)->toHaveKey('role-1'); expect($roles)->toHaveKey('role-1');
expect($roles['role-1'])->toContain('Policy and Profile Manager'); expect($roles['role-1'])->toContain('Policy and Profile Manager');
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('setup_rbac') ->mountAction('setup_rbac')
->setActionData([ ->setActionData([
'role_definition_id' => 'role-1', 'role_definition_id' => 'role-1',

View File

@ -25,10 +25,10 @@
[$user] = createUserWithTenant($tenantA, role: 'owner'); [$user] = createUserWithTenant($tenantA, role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->get('/admin/choose-tenant') ->get('/admin/choose-tenant')
->assertOk() ->assertOk()
->assertSee('Tenant A') ->assertSee('Tenant A')
->assertDontSee('Tenant B'); ->assertDontSee('Tenant B');
}); });
it('scopes global search results to the current tenant and denies non-members', function (): void { it('scopes global search results to the current tenant and denies non-members', function (): void {

View File

@ -1,6 +1,6 @@
<?php <?php
use App\Filament\Pages\ManagedTenants\Onboarding; use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPermission; use App\Models\TenantPermission;
@ -55,16 +55,26 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$contextTenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$contextTenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-context', 'tenant_id' => 'tenant-context',
'name' => 'Context Tenant', 'name' => 'Context Tenant',
]); ]);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$contextTenant->getKey() => ['role' => 'owner'], $contextTenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($contextTenant, true); Filament::setTenant($contextTenant, true);
Livewire::test(Onboarding::class) Livewire::actingAs($user)
->test(CreateTenant::class)
->fillForm([ ->fillForm([
'name' => 'Contoso', 'name' => 'Contoso',
'environment' => 'other', 'environment' => 'other',
@ -79,7 +89,8 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first();
expect($tenant)->not->toBeNull(); expect($tenant)->not->toBeNull();
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify'); ->callAction('verify');
$tenant->refresh(); $tenant->refresh();
@ -136,17 +147,27 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-error', 'tenant_id' => 'tenant-error',
'name' => 'Error Tenant', 'name' => 'Error Tenant',
]); ]);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify'); ->callAction('verify');
$tenant->refresh(); $tenant->refresh();
@ -169,10 +190,19 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-ui', 'tenant_id' => 'tenant-ui',
'name' => 'UI Tenant', 'name' => 'UI Tenant',
]); ]);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
@ -201,11 +231,20 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-ui-list', 'tenant_id' => 'tenant-ui-list',
'name' => 'UI Tenant List', 'name' => 'UI Tenant List',
'app_client_id' => 'client-123', 'app_client_id' => 'client-123',
]); ]);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
@ -221,17 +260,27 @@ public function request(string $method, string $path, array $options = []): Grap
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$tenant = Tenant::create([ $workspace = \App\Models\Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'tenant_id' => 'tenant-ui-deactivate', 'tenant_id' => 'tenant-ui-deactivate',
'name' => 'UI Tenant Deactivate', 'name' => 'UI Tenant Deactivate',
]); ]);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'], $tenant->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('archive') ->mountAction('archive')
->callMountedAction() ->callMountedAction()
->assertHasNoActionErrors(); ->assertHasNoActionErrors();

View File

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
test('legacy onboarding redirect tests moved to workspace suite', function (): void {
// Intentionally left empty.
})->skip('Moved to tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php');

View File

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
test('managed tenant scoping tests moved to workspace suite', function (): void {
// Intentionally left empty.
})->skip('Moved to tests/Feature/Workspaces/ManagedTenantScopingTest.php');

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Filament\Resources\Workspaces\RelationManagers\MembershipsRelationManager;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('prevents removing or demoting the last owner', function (): void {
$workspace = Workspace::factory()->create();
$owner = User::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($owner)->create(['role' => 'owner']);
$ownerMembership = WorkspaceMembership::query()
->where('workspace_id', $workspace->getKey())
->where('user_id', $owner->getKey())
->firstOrFail();
Livewire::actingAs($owner)
->test(MembershipsRelationManager::class, [
'ownerRecord' => $workspace,
'pageClass' => ViewWorkspace::class,
])
->callTableAction('change_role', $ownerMembership, [
'role' => 'manager',
]);
expect($ownerMembership->refresh()->role)->toBe('owner');
Livewire::actingAs($owner)
->test(MembershipsRelationManager::class, [
'ownerRecord' => $workspace,
'pageClass' => ViewWorkspace::class,
])
->callTableAction('remove', $ownerMembership);
expect(WorkspaceMembership::query()->whereKey($ownerMembership->getKey())->exists())->toBeTrue();
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects legacy onboarding entry to choose-workspace when multiple workspaces and none selected', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$this->get('/admin/new')
->assertRedirect('/admin/choose-workspace');
});
it('redirects legacy onboarding entry to canonical workspace onboarding when a workspace is resolved', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$canonicalUrl = '/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding';
$this->get('/admin/new')
->assertRedirect($canonicalUrl);
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('scopes managed tenant listing to the current workspace', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']);
$tenantA = Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'name' => 'Tenant Alpha',
'is_current' => true,
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'name' => 'Tenant Beta',
'is_current' => false,
]);
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => $workspaceA->getKey()]);
$tenantIds = TenantResource::getEloquentQuery()->pluck('id')->all();
expect($tenantIds)
->toContain($tenantA->getKey())
->not()->toContain($tenantB->getKey());
});
it('returns no managed tenants when no workspace is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$otherWorkspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($otherWorkspace)->for($user)->create(['role' => 'owner']);
Tenant::factory()->create(['workspace_id' => $workspace->getKey()]);
$this->actingAs($user);
$tenantIds = TenantResource::getEloquentQuery()->pluck('id')->all();
expect($tenantIds)->toBeEmpty();
});

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use Illuminate\Database\QueryException;
it('enforces globally unique Entra tenant id (tenants.tenant_id)', function () {
Tenant::factory()->create([
'tenant_id' => '00000000-0000-0000-0000-000000000001',
'external_id' => '00000000-0000-0000-0000-000000000001',
'is_current' => true,
]);
expect(fn () => Tenant::factory()->create([
'tenant_id' => '00000000-0000-0000-0000-000000000001',
'external_id' => '00000000-0000-0000-0000-000000000002',
'is_current' => false,
]))->toThrow(QueryException::class);
});

View File

@ -0,0 +1,101 @@
<?php
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
function workspaceBackfillMigrationPath(): string
{
return database_path('migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php');
}
it('backfills a default workspace and assigns legacy tenants to it', function () {
Workspace::query()->delete();
$tenantA = Tenant::factory()->create(['workspace_id' => null, 'is_current' => true]);
$tenantB = Tenant::factory()->create(['workspace_id' => null, 'is_current' => false]);
$userOwner = User::factory()->create();
$userReadonly = User::factory()->create();
TenantMembership::query()->create([
'tenant_id' => $tenantA->getKey(),
'user_id' => $userOwner->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
TenantMembership::query()->create([
'tenant_id' => $tenantB->getKey(),
'user_id' => $userReadonly->getKey(),
'role' => 'readonly',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$migration = require workspaceBackfillMigrationPath();
$migration->up();
$defaultWorkspace = Workspace::query()->where('slug', 'default')->first();
expect($defaultWorkspace)->not->toBeNull();
$tenantA->refresh();
$tenantB->refresh();
expect($tenantA->workspace_id)->toBe($defaultWorkspace->getKey())
->and($tenantB->workspace_id)->toBe($defaultWorkspace->getKey());
$memberships = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspace->getKey())
->get(['user_id', 'role']);
expect($memberships)->toHaveCount(2)
->and($memberships->firstWhere('user_id', $userOwner->getKey())->role)->toBe('owner')
->and($memberships->firstWhere('user_id', $userReadonly->getKey())->role)->toBe('readonly');
$userOwner->refresh();
$userReadonly->refresh();
expect($userOwner->last_workspace_id)->toBe($defaultWorkspace->getKey())
->and($userReadonly->last_workspace_id)->toBe($defaultWorkspace->getKey());
});
it('is idempotent when run multiple times', function () {
Workspace::query()->delete();
$tenant = Tenant::factory()->create(['workspace_id' => null]);
$user = User::factory()->create();
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'manager',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$migration = require workspaceBackfillMigrationPath();
$migration->up();
$migration->up();
$defaultWorkspace = Workspace::query()->where('slug', 'default')->firstOrFail();
expect(Workspace::query()->where('slug', 'default')->count())->toBe(1);
$tenant->refresh();
expect($tenant->workspace_id)->toBe($defaultWorkspace->getKey());
expect(DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspace->getKey())
->where('user_id', $user->getKey())
->count())->toBe(1);
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
it('has deterministic workspace role capability mappings (golden)', function () {
expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Owner))->toEqual([
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MANAGE,
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
]);
expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Manager))->toEqual([
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
]);
expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Operator))->toEqual([
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
]);
expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Readonly))->toEqual([
Capabilities::WORKSPACE_VIEW,
]);
});
it('does not grant unknown capabilities via workspace role map', function () {
$allCapabilities = Capabilities::all();
foreach (WorkspaceRole::cases() as $role) {
foreach (WorkspaceRoleCapabilityMap::getCapabilities($role) as $capability) {
expect($allCapabilities)->toContain($capability);
}
}
});

View File

@ -0,0 +1,62 @@
<?php
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('allows creating the first workspace from the no-access page', function () {
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(NoAccess::class)
->mountAction('createWorkspace')
->set('mountedActions.0.data.name', 'Acme')
->set('mountedActions.0.data.slug', 'acme')
->callMountedAction()
->assertHasNoActionErrors();
$workspace = Workspace::query()->where('slug', 'acme')->firstOrFail();
expect(WorkspaceMembership::query()
->where('workspace_id', $workspace->getKey())
->where('user_id', $user->getKey())
->where('role', 'owner')
->exists())->toBeTrue();
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($workspace->getKey());
expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey());
});
it('allows creating a new workspace from the choose-workspace page', function () {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
Livewire::test(ChooseWorkspace::class)
->mountAction('createWorkspace')
->set('mountedActions.0.data.name', 'New Workspace')
->set('mountedActions.0.data.slug', 'new-workspace')
->callMountedAction()
->assertHasNoActionErrors();
$workspace = Workspace::query()->where('slug', 'new-workspace')->firstOrFail();
expect(WorkspaceMembership::query()
->where('workspace_id', $workspace->getKey())
->where('user_id', $user->getKey())
->where('role', 'owner')
->exists())->toBeTrue();
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($workspace->getKey());
expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey());
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('scopes TenantResource global search query to the current workspace', function () {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']);
$tenantA = Tenant::factory()->create([
'name' => 'Tenant Alpha',
'workspace_id' => $workspaceA->getKey(),
'is_current' => true,
]);
$tenantB = Tenant::factory()->create([
'name' => 'Tenant Gamma',
'workspace_id' => $workspaceB->getKey(),
'is_current' => false,
]);
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => $workspaceA->getKey()]);
$tenantIds = TenantResource::getGlobalSearchEloquentQuery()->pluck('id')->all();
expect($tenantIds)
->toContain($tenantA->getKey())
->not()->toContain($tenantB->getKey());
});
it('returns no global search results when no workspace is selected', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
Tenant::factory()->create(['workspace_id' => $workspace->getKey()]);
$this->actingAs($user);
$tenantIds = TenantResource::getGlobalSearchEloquentQuery()->pluck('id')->all();
expect($tenantIds)->toBeEmpty();
});

View File

@ -0,0 +1,47 @@
<?php
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
it('denies non-members with not-found semantics for workspace-scoped routes', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$this->actingAs($user);
$this->get("/admin/w/{$workspace->getKey()}/ping")
->assertNotFound();
});
it('allows members to access workspace-scoped routes', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$this->get("/admin/w/{$workspace->getKey()}/ping")
->assertNoContent();
});
it('redirects members from the workspace root into the admin panel', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$this->get("/admin/w/{$workspace->getKey()}")
->assertRedirect('/admin/tenants');
});
it('denies non-members with not-found semantics for the workspace root', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$this->actingAs($user);
$this->get("/admin/w/{$workspace->getKey()}")
->assertNotFound();
});

View File

@ -0,0 +1,68 @@
<?php
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('invalidates selection and redirects to no-access when the only membership workspace is archived', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$workspace->forceFill(['archived_at' => now()])->save();
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => $workspace->getKey()]);
$this->get('/admin/_test/workspace-context')
->assertRedirect('/admin/no-access');
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull();
expect($user->fresh()->last_workspace_id)->toBeNull();
});
it('auto-selects another active workspace when the current selection becomes archived', function () {
$user = User::factory()->create();
$archived = Workspace::factory()->create();
$active = Workspace::factory()->create();
WorkspaceMembership::factory()->for($archived)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($active)->for($user)->create(['role' => 'owner']);
$archived->forceFill(['archived_at' => now()])->save();
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => $archived->getKey()]);
$user->forceFill(['last_workspace_id' => $archived->getKey()])->save();
$this->get('/admin/_test/workspace-context')
->assertSuccessful()
->assertJson([
'workspace_id' => $active->getKey(),
]);
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($active->getKey());
expect($user->fresh()->last_workspace_id)->toBe($active->getKey());
});
it('invalidates selection and redirects to no-access when membership is removed', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$membership = WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => $workspace->getKey()]);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$membership->delete();
$this->get('/admin/_test/workspace-context')
->assertRedirect('/admin/no-access');
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull();
expect($user->fresh()->last_workspace_id)->toBeNull();
});

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use App\Models\User;
it('allows users to log out even when no workspace is selected', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post('/admin/logout')
->assertRedirect('/admin/login');
expect(auth()->check())->toBeFalse();
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('logs membership add with workspace context', function (): void {
$workspace = Workspace::factory()->create();
$actor = User::factory()->create();
$member = User::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($actor)->create(['role' => 'owner']);
/** @var WorkspaceMembershipManager $manager */
$manager = app(WorkspaceMembershipManager::class);
$manager->addMember(
workspace: $workspace,
actor: $actor,
member: $member,
role: 'readonly',
source: 'manual',
);
$log = AuditLog::query()
->where('workspace_id', $workspace->getKey())
->where('action', AuditActionId::WorkspaceMembershipAdd->value)
->orderByDesc('id')
->first();
expect($log)->not->toBeNull();
expect($log?->tenant_id)->toBeNull();
expect($log?->status)->toBe('success');
expect($log?->metadata)->toMatchArray([
'member_user_id' => $member->getKey(),
'role' => 'readonly',
'source' => 'manual',
]);
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns 404 for non-members (deny as not found)', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$this->actingAs($user);
Livewire::test(ViewWorkspace::class, ['record' => $workspace->getRouteKey()])
->assertStatus(404);
});
it('returns 403 for members without membership manage capability', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'readonly']);
$this->actingAs($user);
expect(fn () => Gate::forUser($user)->authorize(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, $workspace))
->toThrow(AuthorizationException::class);
});

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Database\QueryException;
it('enforces unique workspace membership per (workspace_id, user_id)', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
expect(fn () => WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'operator']))
->toThrow(QueryException::class);
});

View File

@ -0,0 +1,79 @@
<?php
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
it('uses session workspace id when valid', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
session([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => $workspace->getKey()]);
$this->get('/admin/_test/workspace-context')
->assertSuccessful()
->assertJson([
'workspace_id' => $workspace->getKey(),
]);
});
it('falls back to last_workspace_id when session workspace is invalid', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$this->actingAs($user);
session([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => 999999]);
$this->get('/admin/_test/workspace-context')
->assertSuccessful()
->assertJson([
'workspace_id' => $workspace->getKey(),
]);
});
it('auto-selects the only membership workspace when nothing is selected', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$this->get('/admin/_test/workspace-context')
->assertSuccessful()
->assertJson([
'workspace_id' => $workspace->getKey(),
]);
expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey());
});
it('redirects to no-access when user has zero workspace memberships', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get('/admin/_test/workspace-context')
->assertRedirect('/admin/no-access');
});
it('redirects to choose-workspace when user has multiple workspaces and none selected', function () {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']);
WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']);
$this->actingAs($user);
$this->get('/admin/_test/workspace-context')
->assertRedirect('/admin/choose-workspace');
});

View File

@ -2,6 +2,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\AssertsNoOutboundHttp; use Tests\Support\AssertsNoOutboundHttp;
@ -90,6 +92,25 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
$user ??= User::factory()->create(); $user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create(); $tenant ??= Tenant::factory()->create();
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
$workspace = Workspace::factory()->create();
$tenant->forceFill(['workspace_id' => $workspace->getKey()])->save();
}
WorkspaceMembership::query()->firstOrCreate(
[
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
],
[
'role' => $role,
],
);
$user->forceFill(['last_workspace_id' => $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => $role], $tenant->getKey() => ['role' => $role],
]); ]);

View File

@ -42,6 +42,7 @@
it('uses the contract assignment payload key for assign actions', function () { it('uses the contract assignment payload key for assign actions', function () {
$tenant = Tenant::factory()->make([ $tenant = Tenant::factory()->make([
'workspace_id' => 1,
'tenant_id' => 'tenant-123', 'tenant_id' => 'tenant-123',
'app_client_id' => null, 'app_client_id' => null,
'app_client_secret' => null, 'app_client_secret' => null,
@ -93,6 +94,7 @@
it('uses derived assign endpoints for app protection policies', function () { it('uses derived assign endpoints for app protection policies', function () {
$tenant = Tenant::factory()->make([ $tenant = Tenant::factory()->make([
'workspace_id' => 1,
'tenant_id' => 'tenant-123', 'tenant_id' => 'tenant-123',
'app_client_id' => null, 'app_client_id' => null,
'app_client_secret' => null, 'app_client_secret' => null,
@ -141,6 +143,7 @@
it('maps assignment filter ids stored at the root of assignments', function () { it('maps assignment filter ids stored at the root of assignments', function () {
$tenant = Tenant::factory()->make([ $tenant = Tenant::factory()->make([
'workspace_id' => 1,
'tenant_id' => 'tenant-123', 'tenant_id' => 'tenant-123',
'app_client_id' => null, 'app_client_id' => null,
'app_client_secret' => null, 'app_client_secret' => null,
@ -201,6 +204,7 @@
it('keeps assignment filters when mapping is missing but filter exists in target', function () { it('keeps assignment filters when mapping is missing but filter exists in target', function () {
$tenant = Tenant::factory()->make([ $tenant = Tenant::factory()->make([
'workspace_id' => 1,
'tenant_id' => 'tenant-123', 'tenant_id' => 'tenant-123',
'app_client_id' => null, 'app_client_id' => null,
'app_client_secret' => null, 'app_client_secret' => null,

View File

@ -11,7 +11,7 @@
it('correctly identifies non-member as deny-as-not-found', function () { it('correctly identifies non-member as deny-as-not-found', function () {
$context = new TenantAccessContext( $context = new TenantAccessContext(
user: User::factory()->make(), user: User::factory()->make(),
tenant: Tenant::factory()->make(), tenant: Tenant::factory()->make(['workspace_id' => 1]),
isMember: false, isMember: false,
hasCapability: false, hasCapability: false,
); );
@ -24,7 +24,7 @@
it('correctly identifies member without capability as forbidden', function () { it('correctly identifies member without capability as forbidden', function () {
$context = new TenantAccessContext( $context = new TenantAccessContext(
user: User::factory()->make(), user: User::factory()->make(),
tenant: Tenant::factory()->make(), tenant: Tenant::factory()->make(['workspace_id' => 1]),
isMember: true, isMember: true,
hasCapability: false, hasCapability: false,
); );
@ -37,7 +37,7 @@
it('correctly identifies authorized member', function () { it('correctly identifies authorized member', function () {
$context = new TenantAccessContext( $context = new TenantAccessContext(
user: User::factory()->make(), user: User::factory()->make(),
tenant: Tenant::factory()->make(), tenant: Tenant::factory()->make(['workspace_id' => 1]),
isMember: true, isMember: true,
hasCapability: true, hasCapability: true,
); );