wip: save 068-workspaces-v2 worktree state
This commit is contained in:
parent
3490fb9e2c
commit
6b8f076d4a
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -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 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (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)
|
||||
|
||||
@ -33,9 +35,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## 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
|
||||
- 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 -->
|
||||
|
||||
@ -50,7 +50,8 @@ ### Tenant Isolation is Non-negotiable
|
||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
|
||||
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.
|
||||
- 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.
|
||||
@ -69,11 +70,11 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
- Any missing server-side authorization is a P0 security bug.
|
||||
|
||||
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||
- Tenant membership (and plane membership) is an isolation boundary.
|
||||
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
||||
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||
action endpoints (Livewire calls included).
|
||||
- Workspace membership and tenant membership (and plane membership) are isolation boundaries.
|
||||
- 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 scope-scoped routes/actions/resources.
|
||||
- This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`),
|
||||
Global Search results, and all action endpoints (Livewire calls included).
|
||||
|
||||
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
53
app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php
Normal file
53
app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php
Normal 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);
|
||||
}
|
||||
}
|
||||
137
app/Filament/Pages/ChooseWorkspace.php
Normal file
137
app/Filament/Pages/ChooseWorkspace.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,13 @@
|
||||
|
||||
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;
|
||||
|
||||
class NoAccess extends Page
|
||||
@ -19,4 +26,60 @@ class NoAccess extends Page
|
||||
protected static ?string $title = '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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToWorkspace;
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
@ -9,8 +10,10 @@
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -30,6 +33,7 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -53,6 +57,8 @@
|
||||
|
||||
class TenantResource extends Resource
|
||||
{
|
||||
use ScopesGlobalSearchToWorkspace;
|
||||
|
||||
// ... [Properties Omitted for Brevity] ...
|
||||
protected static ?string $model = Tenant::class;
|
||||
|
||||
@ -70,7 +76,7 @@ public static function canCreate(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return static::userCanManageAnyTenant($user);
|
||||
return static::userCanManageTenantsInCurrentWorkspace($user);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
@ -81,11 +87,12 @@ public static function canEdit(Model $record): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
&& $workspace instanceof Workspace
|
||||
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
||||
&& static::userCanManageTenantsInCurrentWorkspace($user);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
@ -96,11 +103,12 @@ public static function canDelete(Model $record): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
&& $workspace instanceof Workspace
|
||||
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
||||
&& static::userCanDeleteTenantsInCurrentWorkspace($user);
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
@ -111,21 +119,49 @@ public static function canDeleteAny(): bool
|
||||
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()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||
|
||||
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()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||
|
||||
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
|
||||
@ -172,20 +208,21 @@ public static function form(Schema $schema): Schema
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
// ... [Query Omitted - No Change] ...
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()
|
||||
->withTrashed()
|
||||
->pluck('tenants.id');
|
||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->withCount('policies')
|
||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||
}
|
||||
@ -873,8 +910,12 @@ public static function rbacAction(): Actions\Action
|
||||
->noSearchResultsMessage('No security groups found')
|
||||
->loadingMessage('Searching groups...'),
|
||||
])
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->isActive())
|
||||
->disabled(function (?Tenant $record): bool {
|
||||
if ($record === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
|
||||
@ -4,12 +4,27 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTenant extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspace = app(WorkspaceContext::class)->currentWorkspace(request());
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$data['workspace_id'] = (int) $workspace->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -14,8 +14,9 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('Add managed tenant')
|
||||
->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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
11
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal 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;
|
||||
}
|
||||
21
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
21
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
29
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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([]);
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Workspaces/Schemas/WorkspaceForm.php
Normal file
19
app/Filament/Resources/Workspaces/Schemas/WorkspaceForm.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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('-'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Filament/Resources/Workspaces/Tables/WorkspacesTable.php
Normal file
88
app/Filament/Resources/Workspaces/Tables/WorkspacesTable.php
Normal 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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@ -21,4 +21,9 @@ public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
@ -170,6 +171,11 @@ public function memberships(): HasMany
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function roleMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantRoleMapping::class);
|
||||
|
||||
43
app/Models/Workspace.php
Normal file
43
app/Models/Workspace.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Models/WorkspaceMembership.php
Normal file
31
app/Models/WorkspaceMembership.php
Normal 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);
|
||||
}
|
||||
}
|
||||
108
app/Policies/WorkspaceMembershipPolicy.php
Normal file
108
app/Policies/WorkspaceMembershipPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
app/Policies/WorkspacePolicy.php
Normal file
74
app/Policies/WorkspacePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,13 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\WorkspaceMembershipPolicy;
|
||||
use App\Policies\WorkspacePolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
@ -17,21 +22,36 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
Workspace::class => WorkspacePolicy::class,
|
||||
WorkspaceMembership::class => WorkspaceMembershipPolicy::class,
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
||||
return $resolver->can($user, $tenant, $capability);
|
||||
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
||||
Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool {
|
||||
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) {
|
||||
if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) {
|
||||
$defineWorkspaceCapability($capability);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$defineTenantCapability($capability);
|
||||
}
|
||||
|
||||
|
||||
@ -4,11 +4,15 @@
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@ -23,6 +27,7 @@
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
@ -38,7 +43,17 @@ public function panel(Panel $panel): Panel
|
||||
->login(Login::class)
|
||||
->authenticatedRoutes(function (Panel $panel): void {
|
||||
ChooseTenant::registerRoutes($panel);
|
||||
ChooseWorkspace::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');
|
||||
}
|
||||
})
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
@ -70,6 +85,24 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->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([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
@ -85,6 +118,7 @@ public function panel(Panel $panel): Panel
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
|
||||
40
app/Services/Audit/WorkspaceAuditLogger.php
Normal file
40
app/Services/Audit/WorkspaceAuditLogger.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal file
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal 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];
|
||||
}
|
||||
}
|
||||
274
app/Services/Auth/WorkspaceMembershipManager.php
Normal file
274
app/Services/Auth/WorkspaceMembershipManager.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal file
74
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -16,4 +16,9 @@ enum AuditActionId: string
|
||||
|
||||
// Diagnostics / repair actions.
|
||||
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';
|
||||
}
|
||||
|
||||
@ -55,6 +55,18 @@ class Capabilities
|
||||
// Audit
|
||||
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
|
||||
*
|
||||
|
||||
11
app/Support/Auth/WorkspaceRole.php
Normal file
11
app/Support/Auth/WorkspaceRole.php
Normal 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';
|
||||
}
|
||||
@ -6,7 +6,10 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
@ -349,10 +352,42 @@ private function applyServerSideGuard(): void
|
||||
/**
|
||||
* 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();
|
||||
|
||||
$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
|
||||
$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.
|
||||
*
|
||||
|
||||
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal file
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
135
app/Support/Workspaces/WorkspaceContext.php
Normal file
135
app/Support/Workspaces/WorkspaceContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Support/Workspaces/WorkspaceResolver.php
Normal file
25
app/Support/Workspaces/WorkspaceResolver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@
|
||||
$middleware->alias([
|
||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::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(
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -17,6 +18,7 @@ class TenantFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => fake()->company(),
|
||||
'external_id' => fake()->uuid(),
|
||||
'tenant_id' => fake()->uuid(),
|
||||
|
||||
27
database/factories/WorkspaceFactory.php
Normal file
27
database/factories/WorkspaceFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
database/factories/WorkspaceMembershipFactory.php
Normal file
25
database/factories/WorkspaceMembershipFactory.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,12 @@ public function up(): void
|
||||
->where('tenant_id', 'local-tenant')
|
||||
->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');
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
38
resources/views/filament/pages/choose-workspace.blade.php
Normal file
38
resources/views/filament/pages/choose-workspace.blade.php
Normal 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>
|
||||
@ -1,11 +1,12 @@
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
You don’t have access to any tenants yet.
|
||||
You don’t have access to any workspaces yet.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</x-filament::section>
|
||||
|
||||
@ -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>
|
||||
@ -4,7 +4,17 @@
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
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\Http\Request;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
@ -16,6 +26,24 @@
|
||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||
->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'])
|
||||
->name('admin.rbac.start');
|
||||
|
||||
@ -28,3 +56,52 @@
|
||||
Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
|
||||
->middleware('throttle: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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
35
specs/068-workspaces-v2/checklists/requirements.md
Normal file
35
specs/068-workspaces-v2/checklists/requirements.md
Normal 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.
|
||||
@ -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: []
|
||||
81
specs/068-workspaces-v2/data-model.md
Normal file
81
specs/068-workspaces-v2/data-model.md
Normal 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`.
|
||||
192
specs/068-workspaces-v2/plan.md
Normal file
192
specs/068-workspaces-v2/plan.md
Normal 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] |
|
||||
45
specs/068-workspaces-v2/quickstart.md
Normal file
45
specs/068-workspaces-v2/quickstart.md
Normal 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`
|
||||
105
specs/068-workspaces-v2/research.md
Normal file
105
specs/068-workspaces-v2/research.md
Normal 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 doesn’t 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).
|
||||
214
specs/068-workspaces-v2/spec.md
Normal file
214
specs/068-workspaces-v2/spec.md
Normal 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 member’s 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 user’s 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 user’s 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 user’s 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 user’s 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.
|
||||
175
specs/068-workspaces-v2/tasks.md
Normal file
175
specs/068-workspaces-v2/tasks.md
Normal 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).
|
||||
@ -3,6 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@ -16,6 +18,10 @@
|
||||
$user = User::factory()->create();
|
||||
$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/choose-tenant')->assertOk();
|
||||
});
|
||||
|
||||
@ -14,5 +14,5 @@
|
||||
$response = $this->get('/admin/no-access');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('You don’t have access to any tenants yet.');
|
||||
$response->assertSee('You don’t have access to any workspaces yet.');
|
||||
});
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
||||
config()->set('graph.enabled', true);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
@ -398,19 +398,29 @@
|
||||
});
|
||||
|
||||
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',
|
||||
'name' => 'Tenant 4',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListTenants::class)
|
||||
Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->callTableAction('archive', $tenant);
|
||||
|
||||
expect(Tenant::count())->toBe(0);
|
||||
@ -436,12 +446,16 @@
|
||||
});
|
||||
|
||||
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',
|
||||
'name' => 'Active Tenant',
|
||||
]);
|
||||
|
||||
$archived = Tenant::create([
|
||||
$archived = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-archived',
|
||||
'name' => 'Archived Tenant',
|
||||
]);
|
||||
@ -450,13 +464,20 @@
|
||||
|
||||
$user = User::factory()->create();
|
||||
$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([
|
||||
$active->getKey() => ['role' => 'owner'],
|
||||
$archived->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($active, true);
|
||||
|
||||
$component = Livewire::test(ListTenants::class)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertSee($active->name)
|
||||
->assertSee($archived->name);
|
||||
|
||||
@ -472,27 +493,38 @@
|
||||
});
|
||||
|
||||
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',
|
||||
'name' => 'Restore Tenant',
|
||||
]);
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
$contextTenant = Tenant::create([
|
||||
$contextTenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-restore-context',
|
||||
'name' => 'Restore Context Tenant',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
$contextTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($contextTenant, true);
|
||||
|
||||
Livewire::test(ListTenants::class)
|
||||
Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->set('tableFilters.trashed.value', 1)
|
||||
->callTableAction('restore', $tenant);
|
||||
|
||||
|
||||
@ -28,16 +28,11 @@
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-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(
|
||||
filamentTenantRouteParams($unauthorizedTenant),
|
||||
['record' => $unauthorizedTenant],
|
||||
@ -45,16 +40,11 @@
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
$authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-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(
|
||||
filamentTenantRouteParams($unauthorizedTenant),
|
||||
['record' => $unauthorizedTenant],
|
||||
@ -62,23 +52,17 @@
|
||||
});
|
||||
|
||||
test('tenant portfolio lists only tenants the user can access', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$authorizedTenant = Tenant::factory()->create([
|
||||
[$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-portfolio-authorized',
|
||||
'name' => 'Authorized Tenant',
|
||||
]);
|
||||
]), role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$unauthorizedTenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-portfolio-unauthorized',
|
||||
'name' => 'Unauthorized Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$authorizedTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
|
||||
->assertOk()
|
||||
->assertSee($authorizedTenant->name)
|
||||
@ -88,11 +72,19 @@
|
||||
test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () {
|
||||
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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']);
|
||||
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']);
|
||||
\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([
|
||||
$tenantA->getKey() => ['role' => 'owner'],
|
||||
@ -101,7 +93,8 @@
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
Livewire::test(ListTenants::class)
|
||||
Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertTableBulkActionVisible('syncSelected')
|
||||
->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB]));
|
||||
|
||||
@ -118,10 +111,17 @@
|
||||
test('tenant portfolio bulk sync is disabled for readonly users', function () {
|
||||
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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']);
|
||||
\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([
|
||||
$tenant->getKey() => ['role' => 'readonly'],
|
||||
@ -146,11 +146,18 @@
|
||||
test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () {
|
||||
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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']);
|
||||
$tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']);
|
||||
\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([
|
||||
$tenantA->getKey() => ['role' => 'owner'],
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Filament\Facades\Filament;
|
||||
@ -20,7 +22,10 @@
|
||||
|
||||
function tenantWithApp(): Tenant
|
||||
{
|
||||
return Tenant::create([
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
return Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-guid',
|
||||
'name' => 'Tenant One',
|
||||
'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 () {
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('setup_rbac')
|
||||
->setActionData([
|
||||
'role_definition_id' => 'role-1',
|
||||
@ -56,6 +74,7 @@ function tenantWithApp(): Tenant
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$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')
|
||||
->setActionData([
|
||||
'role_definition_id' => 'role-1',
|
||||
@ -164,6 +184,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$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')
|
||||
->setActionData([
|
||||
'role_definition_id' => 'role-1',
|
||||
@ -278,6 +300,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$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')
|
||||
->setActionData([
|
||||
'role_definition_id' => 'role-1',
|
||||
@ -382,12 +406,14 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('setup_rbac')
|
||||
->setActionData([
|
||||
'group_mode' => 'existing',
|
||||
@ -401,12 +427,14 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('setup_rbac')
|
||||
->setActionData([
|
||||
'group_mode' => 'existing',
|
||||
@ -419,6 +447,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$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['group-123'])->toContain('Ops Team');
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('setup_rbac')
|
||||
->setActionData([
|
||||
'group_mode' => 'existing',
|
||||
@ -534,6 +564,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$tenant = tenantWithApp();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
prepareWorkspaceContextFor($user, $tenant);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$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['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')
|
||||
->setActionData([
|
||||
'role_definition_id' => 'role-1',
|
||||
|
||||
@ -25,10 +25,10 @@
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/choose-tenant')
|
||||
->assertOk()
|
||||
->assertSee('Tenant A')
|
||||
->assertDontSee('Tenant B');
|
||||
->get('/admin/choose-tenant')
|
||||
->assertOk()
|
||||
->assertSee('Tenant A')
|
||||
->assertDontSee('Tenant B');
|
||||
});
|
||||
|
||||
it('scopes global search results to the current tenant and denies non-members', function (): void {
|
||||
|
||||
@ -55,16 +55,26 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$contextTenant = Tenant::create([
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
|
||||
$contextTenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-context',
|
||||
'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([
|
||||
$contextTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
Filament::setTenant($contextTenant, true);
|
||||
|
||||
Livewire::test(CreateTenant::class)
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateTenant::class)
|
||||
->fillForm([
|
||||
'name' => 'Contoso',
|
||||
'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();
|
||||
expect($tenant)->not->toBeNull();
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->callAction('verify');
|
||||
|
||||
$tenant->refresh();
|
||||
@ -136,17 +147,27 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-error',
|
||||
'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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->callAction('verify');
|
||||
|
||||
$tenant->refresh();
|
||||
@ -169,10 +190,19 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'tenant_id' => 'tenant-ui',
|
||||
'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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
@ -201,11 +231,20 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$user = User::factory()->create();
|
||||
$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',
|
||||
'name' => 'UI Tenant List',
|
||||
'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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
@ -221,17 +260,27 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$user = User::factory()->create();
|
||||
$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',
|
||||
'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([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
7
tests/Feature/LegacyOnboardingRedirectTest.php
Normal file
7
tests/Feature/LegacyOnboardingRedirectTest.php
Normal 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');
|
||||
7
tests/Feature/ManagedTenantScopingTest.php
Normal file
7
tests/Feature/ManagedTenantScopingTest.php
Normal 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');
|
||||
45
tests/Feature/Workspaces/LastOwnerGuardTest.php
Normal file
45
tests/Feature/Workspaces/LastOwnerGuardTest.php
Normal 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();
|
||||
});
|
||||
38
tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php
Normal file
38
tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php
Normal 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);
|
||||
});
|
||||
60
tests/Feature/Workspaces/ManagedTenantScopingTest.php
Normal file
60
tests/Feature/Workspaces/ManagedTenantScopingTest.php
Normal 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();
|
||||
});
|
||||
20
tests/Feature/Workspaces/ManagedTenantUniquenessTest.php
Normal file
20
tests/Feature/Workspaces/ManagedTenantUniquenessTest.php
Normal 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);
|
||||
});
|
||||
101
tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php
Normal file
101
tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php
Normal 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);
|
||||
});
|
||||
42
tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php
Normal file
42
tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
62
tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php
Normal file
62
tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php
Normal 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());
|
||||
});
|
||||
56
tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php
Normal file
56
tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php
Normal 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();
|
||||
});
|
||||
47
tests/Feature/Workspaces/WorkspaceIsolationTest.php
Normal file
47
tests/Feature/Workspaces/WorkspaceIsolationTest.php
Normal 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();
|
||||
});
|
||||
68
tests/Feature/Workspaces/WorkspaceLifecycleTest.php
Normal file
68
tests/Feature/Workspaces/WorkspaceLifecycleTest.php
Normal 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();
|
||||
});
|
||||
16
tests/Feature/Workspaces/WorkspaceLogoutBypassTest.php
Normal file
16
tests/Feature/Workspaces/WorkspaceLogoutBypassTest.php
Normal 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();
|
||||
});
|
||||
46
tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php
Normal file
46
tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
79
tests/Feature/Workspaces/WorkspaceSelectionTest.php
Normal file
79
tests/Feature/Workspaces/WorkspaceSelectionTest.php
Normal 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');
|
||||
});
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Support\AssertsNoOutboundHttp;
|
||||
@ -90,6 +92,25 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
|
||||
$user ??= User::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([
|
||||
$tenant->getKey() => ['role' => $role],
|
||||
]);
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
|
||||
it('uses the contract assignment payload key for assign actions', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'workspace_id' => 1,
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
@ -93,6 +94,7 @@
|
||||
|
||||
it('uses derived assign endpoints for app protection policies', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'workspace_id' => 1,
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
@ -141,6 +143,7 @@
|
||||
|
||||
it('maps assignment filter ids stored at the root of assignments', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'workspace_id' => 1,
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
@ -201,6 +204,7 @@
|
||||
|
||||
it('keeps assignment filters when mapping is missing but filter exists in target', function () {
|
||||
$tenant = Tenant::factory()->make([
|
||||
'workspace_id' => 1,
|
||||
'tenant_id' => 'tenant-123',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
it('correctly identifies non-member as deny-as-not-found', function () {
|
||||
$context = new TenantAccessContext(
|
||||
user: User::factory()->make(),
|
||||
tenant: Tenant::factory()->make(),
|
||||
tenant: Tenant::factory()->make(['workspace_id' => 1]),
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
);
|
||||
@ -24,7 +24,7 @@
|
||||
it('correctly identifies member without capability as forbidden', function () {
|
||||
$context = new TenantAccessContext(
|
||||
user: User::factory()->make(),
|
||||
tenant: Tenant::factory()->make(),
|
||||
tenant: Tenant::factory()->make(['workspace_id' => 1]),
|
||||
isMember: true,
|
||||
hasCapability: false,
|
||||
);
|
||||
@ -37,7 +37,7 @@
|
||||
it('correctly identifies authorized member', function () {
|
||||
$context = new TenantAccessContext(
|
||||
user: User::factory()->make(),
|
||||
tenant: Tenant::factory()->make(),
|
||||
tenant: Tenant::factory()->make(['workspace_id' => 1]),
|
||||
isMember: true,
|
||||
hasCapability: true,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user