Compare commits
20 Commits
dev
...
feat/999-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2581b1407e | ||
|
|
3b16b1b94c | ||
|
|
da05b9f096 | ||
|
|
cffa4053c8 | ||
|
|
14daee6964 | ||
|
|
875aa1eed2 | ||
|
|
21df2056f1 | ||
|
|
31376c422e | ||
|
|
6b8f076d4a | ||
|
|
da610d5c5a | ||
|
|
b8c75fc641 | ||
|
|
458a94c6e9 | ||
|
|
3185ba5791 | ||
|
|
ccfd491260 | ||
|
|
d4148020bc | ||
|
|
ea72c34398 | ||
|
|
95ccc3008c | ||
|
|
a53bb3f708 | ||
|
|
07dda36d6e | ||
|
|
e5ad9b6cf8 |
7
.github/agents/copilot-instructions.md
vendored
7
.github/agents/copilot-instructions.md
vendored
@ -14,6 +14,10 @@ ## Active Technologies
|
|||||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 (068-workspaces-v2)
|
||||||
|
- PostgreSQL (via Sail) (068-workspaces-v2)
|
||||||
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (069-managed-tenant-onboarding-wizard)
|
||||||
|
- PostgreSQL (Sail) (069-managed-tenant-onboarding-wizard)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -33,9 +37,10 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 068-workspaces-v2: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4
|
||||||
|
- 069-managed-tenant-onboarding-wizard: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -50,7 +50,8 @@ ### Tenant Isolation is Non-negotiable
|
|||||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
RBAC Context — Planes, Roles, and Auditability
|
RBAC Context — Planes, Roles, and Auditability
|
||||||
- The platform MUST maintain two strictly separated authorization planes:
|
- The platform MUST maintain strictly separated authorization planes:
|
||||||
|
- Workspace plane (`/admin/w/{workspace}`): authenticated Entra users (`users`), authorization is workspace-scoped.
|
||||||
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
||||||
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
||||||
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
||||||
@ -69,11 +70,11 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
|
|||||||
- Any missing server-side authorization is a P0 security bug.
|
- Any missing server-side authorization is a P0 security bug.
|
||||||
|
|
||||||
RBAC-UX-002 — Deny-as-not-found for non-members
|
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||||
- Tenant membership (and plane membership) is an isolation boundary.
|
- Workspace membership and tenant membership (and plane membership) are isolation boundaries.
|
||||||
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
- If the current actor is not a member of the current workspace or tenant (or otherwise not entitled to the relevant scope), the system MUST
|
||||||
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
respond as 404 (deny-as-not-found) for scope-scoped routes/actions/resources.
|
||||||
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
- This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`),
|
||||||
action endpoints (Livewire calls included).
|
Global Search results, and all action endpoints (Livewire calls included).
|
||||||
|
|
||||||
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||||
- Within an established tenant scope, missing permissions are authorization failures.
|
- Within an established tenant scope, missing permissions are authorization failures.
|
||||||
@ -174,4 +175,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-31
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Filament/Pages/ManagedTenants/ArchivedStatus.php
Normal file
131
app/Filament/Pages/ManagedTenants/ArchivedStatus.php
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedTenants\ManagedTenantContext;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class ArchivedStatus extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants/archived';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Archived managed tenant';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.archived-status';
|
||||||
|
|
||||||
|
public ?Tenant $tenant = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenantMemberships()->exists()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tenant = ManagedTenantContext::archivedTenant();
|
||||||
|
|
||||||
|
if (! $this->tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('back_to_managed_tenants')
|
||||||
|
->label('Back to managed tenants')
|
||||||
|
->url(Index::getUrl()),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(function (): void {
|
||||||
|
$tenant = $this->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->restore();
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
ManagedTenantContext::setCurrentTenant($tenant);
|
||||||
|
ManagedTenantContext::clearArchivedTenant();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Managed tenant restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(Current::getUrl());
|
||||||
|
}),
|
||||||
|
fn () => $this->tenant,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('force_delete')
|
||||||
|
->label('Force delete')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->action(function (): void {
|
||||||
|
$tenant = $this->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->forceDelete();
|
||||||
|
|
||||||
|
ManagedTenantContext::clear();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Managed tenant deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(Index::getUrl());
|
||||||
|
}),
|
||||||
|
fn () => $this->tenant,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Filament/Pages/ManagedTenants/Current.php
Normal file
98
app/Filament/Pages/ManagedTenants/Current.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedTenants\ManagedTenantContext;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class Current extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants/current';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Current managed tenant';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.current';
|
||||||
|
|
||||||
|
public ?Tenant $tenant = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenantMemberships()->exists()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentTenantId = ManagedTenantContext::currentTenantId();
|
||||||
|
|
||||||
|
if (is_int($currentTenantId)) {
|
||||||
|
$selectedTenant = Tenant::withTrashed()->find($currentTenantId);
|
||||||
|
|
||||||
|
if (! $selectedTenant instanceof Tenant) {
|
||||||
|
ManagedTenantContext::clearCurrentTenant();
|
||||||
|
} elseif (! $selectedTenant->isActive()) {
|
||||||
|
ManagedTenantContext::clearCurrentTenant();
|
||||||
|
ManagedTenantContext::setArchivedTenant($selectedTenant);
|
||||||
|
|
||||||
|
$this->redirect(ArchivedStatus::getUrl());
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
$this->tenant = $selectedTenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$canViewAny = Tenant::query()
|
||||||
|
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
|
||||||
|
->cursor()
|
||||||
|
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
|
||||||
|
|
||||||
|
if (! $canViewAny) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active status is already verified above.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Actions\Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('back_to_managed_tenants')
|
||||||
|
->label('Back to managed tenants')
|
||||||
|
->url(Index::getUrl()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Filament/Pages/ManagedTenants/EditManagedTenant.php
Normal file
148
app/Filament/Pages/ManagedTenants/EditManagedTenant.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class EditManagedTenant extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants/{managedTenant}/edit';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Edit managed tenant';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.edit';
|
||||||
|
|
||||||
|
public Tenant $tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public function mount(string $managedTenant): void
|
||||||
|
{
|
||||||
|
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->tenant->isActive()) {
|
||||||
|
\App\Support\ManagedTenants\ManagedTenantContext::setArchivedTenant($this->tenant);
|
||||||
|
|
||||||
|
$this->redirect(ArchivedStatus::getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->form->fill([
|
||||||
|
'name' => $this->tenant->name,
|
||||||
|
'environment' => $this->tenant->environment,
|
||||||
|
'tenant_id' => $this->tenant->tenant_id,
|
||||||
|
'domain' => $this->tenant->domain,
|
||||||
|
'app_client_id' => $this->tenant->app_client_id,
|
||||||
|
'app_client_secret' => null,
|
||||||
|
'app_certificate_thumbprint' => $this->tenant->app_certificate_thumbprint,
|
||||||
|
'app_notes' => $this->tenant->app_notes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Select::make('environment')
|
||||||
|
->options([
|
||||||
|
'prod' => 'PROD',
|
||||||
|
'dev' => 'DEV',
|
||||||
|
'staging' => 'STAGING',
|
||||||
|
'other' => 'Other',
|
||||||
|
])
|
||||||
|
->default('other')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('tenant_id')
|
||||||
|
->label('Tenant ID (GUID)')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('domain')
|
||||||
|
->label('Primary domain')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_id')
|
||||||
|
->label('App Client ID')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_secret')
|
||||||
|
->label('App Client Secret')
|
||||||
|
->password()
|
||||||
|
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||||
|
->dehydrated(fn ($state) => filled($state)),
|
||||||
|
Forms\Components\TextInput::make('app_certificate_thumbprint')
|
||||||
|
->label('Certificate thumbprint')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Textarea::make('app_notes')
|
||||||
|
->label('Notes')
|
||||||
|
->rows(3),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$this->tenant->fill($data);
|
||||||
|
$this->tenant->save();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Managed tenant updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(ViewManagedTenant::getUrl(['tenant' => $this->tenant]));
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Filament/Pages/ManagedTenants/Index.php
Normal file
146
app/Filament/Pages/ManagedTenants/Index.php
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Index extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = true;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Managed tenants';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Managed tenants';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.index';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenantMemberships()->exists()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$canViewAny = Tenant::query()
|
||||||
|
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
|
||||||
|
->cursor()
|
||||||
|
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
|
||||||
|
|
||||||
|
if (! $canViewAny) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('add_managed_tenant')
|
||||||
|
->label('Add managed tenant')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->url('/admin/managed-tenants/onboarding'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query($this->managedTenantsQuery())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')->searchable(),
|
||||||
|
TextColumn::make('tenant_id')->label('Tenant ID')->copyable()->searchable(),
|
||||||
|
TextColumn::make('environment')->badge()->sortable(),
|
||||||
|
TextColumn::make('status')->badge()->sortable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('open')
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open"),
|
||||||
|
fn () => Filament::getTenant(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->url(fn (Tenant $record): string => ViewManagedTenant::getUrl(['managedTenant' => $record])),
|
||||||
|
fn () => Filament::getTenant(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->url(fn (Tenant $record): string => EditManagedTenant::getUrl(['managedTenant' => $record])),
|
||||||
|
fn () => Filament::getTenant(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No managed tenants')
|
||||||
|
->emptyStateDescription('Add your first managed tenant to begin onboarding.')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('empty_add_managed_tenant')
|
||||||
|
->label('Add managed tenant')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->url('/admin/managed-tenants/onboarding'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedTenantsQuery(): Builder
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return Tenant::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $user->tenants()
|
||||||
|
->withTrashed()
|
||||||
|
->pluck('tenants.id');
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->withTrashed()
|
||||||
|
->whereIn('id', $tenantIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Filament/Pages/ManagedTenants/Onboarding.php
Normal file
201
app/Filament/Pages/ManagedTenants/Onboarding.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class Onboarding extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants/onboarding';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Add managed tenant';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.onboarding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
static::abortIfNonMember();
|
||||||
|
|
||||||
|
if (! static::canView()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->form->fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Select::make('environment')
|
||||||
|
->options([
|
||||||
|
'prod' => 'PROD',
|
||||||
|
'dev' => 'DEV',
|
||||||
|
'staging' => 'STAGING',
|
||||||
|
'other' => 'Other',
|
||||||
|
])
|
||||||
|
->default('other')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('tenant_id')
|
||||||
|
->label('Tenant ID (GUID)')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
Forms\Components\TextInput::make('domain')
|
||||||
|
->label('Primary domain')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_id')
|
||||||
|
->label('App Client ID')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('app_client_secret')
|
||||||
|
->label('App Client Secret')
|
||||||
|
->password()
|
||||||
|
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||||
|
->dehydrated(fn ($state) => filled($state)),
|
||||||
|
Forms\Components\TextInput::make('app_certificate_thumbprint')
|
||||||
|
->label('Certificate thumbprint')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Textarea::make('app_notes')
|
||||||
|
->label('Notes')
|
||||||
|
->rows(3),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(AuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
static::abortIfNonMember();
|
||||||
|
|
||||||
|
if (! static::canView()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['workspace_id'] = (int) $workspace->getKey();
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->create($data);
|
||||||
|
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => [
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'created_by_user_id' => $user->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'managed_tenant.onboarding.created',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'internal_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'tenant_guid' => (string) $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Managed tenant added')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function abortIfNonMember(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! static::resolveCurrentWorkspaceFor($user) instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
|
||||||
|
{
|
||||||
|
/** @var WorkspaceContext $context */
|
||||||
|
$context = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
$workspace = $context->resolveInitialWorkspaceFor($user, request());
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context->isMember($user, $workspace) ? $workspace : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Filament/Pages/ManagedTenants/ViewManagedTenant.php
Normal file
86
app/Filament/Pages/ManagedTenants/ViewManagedTenant.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedTenants\ManagedTenantContext;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ViewManagedTenant extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'managed-tenants/{managedTenant}';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Managed tenant';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.managed-tenants.view';
|
||||||
|
|
||||||
|
public Tenant $tenant;
|
||||||
|
|
||||||
|
public function mount(string $managedTenant): void
|
||||||
|
{
|
||||||
|
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->tenant->isActive()) {
|
||||||
|
ManagedTenantContext::setArchivedTenant($this->tenant);
|
||||||
|
|
||||||
|
$this->redirect(ArchivedStatus::getUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('open')
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (): string => "/admin/managed-tenants/{$this->tenant->getKey()}/open"),
|
||||||
|
fn () => $this->tenant,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->url(fn (): string => EditManagedTenant::getUrl(['managedTenant' => $this->tenant])),
|
||||||
|
fn () => $this->tenant,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
class NoAccess extends Page
|
class NoAccess extends Page
|
||||||
@ -19,4 +26,60 @@ class NoAccess extends Page
|
|||||||
protected static ?string $title = 'No access';
|
protected static ?string $title = 'No access';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.no-access';
|
protected string $view = 'filament.pages.no-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('createWorkspace')
|
||||||
|
->label('Create workspace')
|
||||||
|
->modalHeading('Create workspace')
|
||||||
|
->form([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->helperText('Optional. Used in URLs if set.')
|
||||||
|
->maxLength(255)
|
||||||
|
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||||
|
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||||
|
->dehydrated(fn ($state) => filled($state)),
|
||||||
|
])
|
||||||
|
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, slug?: string|null} $data
|
||||||
|
*/
|
||||||
|
public function createWorkspace(array $data): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'slug' => $data['slug'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect('/admin/tenants');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
358
app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php
Normal file
358
app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Onboarding\OnboardingConnectionDiagnosticsJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingInitialSyncJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Onboarding\OnboardingFixHints;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskCatalog;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class TenantOnboardingTaskBoard extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'onboarding/tasks';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Onboarding task board';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.onboarding.tenant-onboarding-task-board';
|
||||||
|
|
||||||
|
public ?OnboardingSession $session = null;
|
||||||
|
|
||||||
|
public bool $canStartProviderTasks = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public array $runUrls = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||||
|
|
||||||
|
$activeSession = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('status', ['draft', 'in_progress'])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $activeSession instanceof OnboardingSession) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session = $activeSession;
|
||||||
|
|
||||||
|
if ($activeSession->current_step < 4) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function latestEvidenceStatusByTaskType(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('task_type', OnboardingTaskType::all())
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byTask = [];
|
||||||
|
|
||||||
|
foreach ($evidence as $row) {
|
||||||
|
if (! isset($byTask[$row->task_type])) {
|
||||||
|
$byTask[$row->task_type] = $row->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, OnboardingEvidence>
|
||||||
|
*/
|
||||||
|
public function latestEvidenceByTaskType(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('task_type', OnboardingTaskType::all())
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byTask = [];
|
||||||
|
|
||||||
|
foreach ($evidence as $row) {
|
||||||
|
if (! isset($byTask[$row->task_type])) {
|
||||||
|
$byTask[$row->task_type] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* task_type: string,
|
||||||
|
* title: string,
|
||||||
|
* step: int,
|
||||||
|
* prerequisites: array<int, string>,
|
||||||
|
* status: string,
|
||||||
|
* badge: \App\Support\Badges\BadgeSpec,
|
||||||
|
* evidence: OnboardingEvidence|null,
|
||||||
|
* prerequisites_met: bool,
|
||||||
|
* unmet_prerequisites: array<int, string>,
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function taskRows(): array
|
||||||
|
{
|
||||||
|
$statuses = $this->latestEvidenceStatusByTaskType();
|
||||||
|
$evidenceByTask = $this->latestEvidenceByTaskType();
|
||||||
|
|
||||||
|
return collect(OnboardingTaskCatalog::all())
|
||||||
|
->map(function (array $task) use ($statuses, $evidenceByTask): array {
|
||||||
|
$taskType = $task['task_type'];
|
||||||
|
$status = $statuses[$taskType] ?? 'unknown';
|
||||||
|
|
||||||
|
$unmet = OnboardingTaskCatalog::unmetPrerequisites($taskType, $statuses);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'title' => $task['title'],
|
||||||
|
'step' => $task['step'],
|
||||||
|
'prerequisites' => $task['prerequisites'],
|
||||||
|
'status' => $status,
|
||||||
|
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $status),
|
||||||
|
'evidence' => $evidenceByTask[$taskType] ?? null,
|
||||||
|
'prerequisites_met' => count($unmet) === 0,
|
||||||
|
'unmet_prerequisites' => $unmet,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function fixHintsFor(?string $reasonCode): array
|
||||||
|
{
|
||||||
|
return OnboardingFixHints::forReason($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* recorded_at: string,
|
||||||
|
* task_type: string,
|
||||||
|
* status: string,
|
||||||
|
* badge: \App\Support\Badges\BadgeSpec,
|
||||||
|
* reason_code: string|null,
|
||||||
|
* message: string|null,
|
||||||
|
* run_url: string|null,
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function recentEvidenceRows(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->limit($limit)
|
||||||
|
->with('operationRun')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $evidence
|
||||||
|
->map(function (OnboardingEvidence $row) use ($tenant): array {
|
||||||
|
$runUrl = null;
|
||||||
|
|
||||||
|
if ($row->operationRun) {
|
||||||
|
$runUrl = OperationRunLinks::view($row->operationRun, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recorded_at' => $row->recorded_at?->toDateTimeString() ?? '',
|
||||||
|
'task_type' => $row->task_type,
|
||||||
|
'status' => $row->status,
|
||||||
|
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $row->status),
|
||||||
|
'reason_code' => $row->reason_code,
|
||||||
|
'message' => $row->message,
|
||||||
|
'run_url' => $runUrl,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startTask(string $taskType): void
|
||||||
|
{
|
||||||
|
if (! $this->canStartProviderTasks) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No onboarding session')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->session->current_step < 4) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($taskType, OnboardingTaskType::all(), true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Unknown task')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestStatuses = $this->latestEvidenceStatusByTaskType();
|
||||||
|
|
||||||
|
if (! OnboardingTaskCatalog::prerequisitesMet($taskType, $latestStatuses)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Prerequisites not met')
|
||||||
|
->body('Complete required tasks first.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionId = $this->session->provider_connection_id;
|
||||||
|
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Select a provider connection first')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($connectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Selected provider connection not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: $taskType,
|
||||||
|
identityInputs: ['task_type' => $taskType],
|
||||||
|
context: [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'onboarding_session_id' => (int) $this->session->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->runUrls[$taskType] = OperationRunLinks::view($run, $tenant);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Task already queued')
|
||||||
|
->body('A run is already queued or running. Use the link to monitor progress.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($taskType) {
|
||||||
|
OnboardingTaskType::VerifyPermissions => OnboardingVerifyPermissionsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::ConsentStatus => OnboardingConsentStatusJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::ConnectionDiagnostics => OnboardingConnectionDiagnosticsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::InitialSync => OnboardingInitialSyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Task queued')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
863
app/Filament/Pages/Onboarding/TenantOnboardingWizard.php
Normal file
863
app/Filament/Pages/Onboarding/TenantOnboardingWizard.php
Normal file
@ -0,0 +1,863 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Onboarding;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||||
|
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Onboarding\LegacyTenantCredentialMigrator;
|
||||||
|
use App\Services\Onboarding\OnboardingLockService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskCatalog;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class TenantOnboardingWizard extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'onboarding';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Onboarding';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.onboarding.tenant-onboarding-wizard';
|
||||||
|
|
||||||
|
public ?OnboardingSession $session = null;
|
||||||
|
|
||||||
|
public bool $canStartProviderTasks = false;
|
||||||
|
|
||||||
|
public bool $canManageProviderConnections = false;
|
||||||
|
|
||||||
|
public bool $canManageTenant = false;
|
||||||
|
|
||||||
|
public bool $hasSessionLock = false;
|
||||||
|
|
||||||
|
public bool $sessionLockedByOther = false;
|
||||||
|
|
||||||
|
public ?string $sessionLockedByLabel = null;
|
||||||
|
|
||||||
|
public ?string $sessionLockedUntil = null;
|
||||||
|
|
||||||
|
public ?int $selectedProviderConnectionId = null;
|
||||||
|
|
||||||
|
public ?string $verifyPermissionsRunUrl = null;
|
||||||
|
|
||||||
|
public ?string $consentStatusRunUrl = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public array $handoffUserOptions = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||||
|
$this->canManageProviderConnections = $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
||||||
|
$this->canManageTenant = $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
|
|
||||||
|
$activeSession = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('status', ['draft', 'in_progress'])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $activeSession instanceof OnboardingSession && $this->canStartProviderTasks) {
|
||||||
|
$activeSession = OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'draft',
|
||||||
|
'current_step' => 1,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session = $activeSession;
|
||||||
|
|
||||||
|
$defaultConnectionId = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('is_default', true)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$this->selectedProviderConnectionId = $this->session?->provider_connection_id
|
||||||
|
?? (is_int($defaultConnectionId) ? $defaultConnectionId : null);
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: $this->canStartProviderTasks);
|
||||||
|
|
||||||
|
if ($this->session instanceof OnboardingSession
|
||||||
|
&& $this->hasSessionLock
|
||||||
|
&& $this->session->provider_connection_id === null
|
||||||
|
&& is_int($this->selectedProviderConnectionId)
|
||||||
|
&& $this->tenantHasLegacyCredentials($tenant)
|
||||||
|
) {
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($this->selectedProviderConnectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$this->session->update(['provider_connection_id' => $connection->getKey()]);
|
||||||
|
$this->session->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantHasLegacyCredentials(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return trim((string) ($tenant->app_client_id ?? '')) !== ''
|
||||||
|
&& trim((string) ($tenant->app_client_secret ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('takeover_onboarding_session')
|
||||||
|
->label('Take over')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Take over onboarding session')
|
||||||
|
->modalDescription('This will take over the onboarding session lock. Use when the current lock holder is unavailable.')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->takeoverSession();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply()
|
||||||
|
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->sessionLockedByOther),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('handoff_onboarding_session')
|
||||||
|
->label('Handoff')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Handoff onboarding session')
|
||||||
|
->modalDescription('Assign onboarding to another tenant member and release your lock.')
|
||||||
|
->form([
|
||||||
|
Select::make('assigned_to_user_id')
|
||||||
|
->label('Assign to')
|
||||||
|
->options(fn (): array => $this->handoffUserOptions)
|
||||||
|
->searchable()
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$assignedToUserId = (int) ($data['assigned_to_user_id'] ?? 0);
|
||||||
|
$this->handoffSession($assignedToUserId);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply()
|
||||||
|
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock),
|
||||||
|
|
||||||
|
Action::make('release_onboarding_lock')
|
||||||
|
->label('Release lock')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Release onboarding lock')
|
||||||
|
->modalDescription('This will release your lock so another user can take over onboarding.')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->releaseSessionLock();
|
||||||
|
}),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('migrate_legacy_credentials')
|
||||||
|
->label('Migrate legacy credentials')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Migrate legacy tenant credentials')
|
||||||
|
->modalDescription('This will copy the tenant\'s legacy app client secret into the selected provider connection credentials. The secret is never displayed.')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->migrateLegacyCredentials();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply()
|
||||||
|
->visible(fn (): bool => $this->canOfferLegacyCredentialMigration()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshCollaborationState(bool $attemptAcquire = false): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hasSessionLock = false;
|
||||||
|
$this->sessionLockedByOther = false;
|
||||||
|
$this->sessionLockedByLabel = null;
|
||||||
|
$this->sessionLockedUntil = null;
|
||||||
|
$this->handoffUserOptions = [];
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canManageTenant) {
|
||||||
|
$this->handoffUserOptions = $tenant->users()
|
||||||
|
->orderBy('name')
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['users.id', 'users.name', 'users.email'])
|
||||||
|
->mapWithKeys(function (User $member): array {
|
||||||
|
$label = trim((string) $member->name) !== ''
|
||||||
|
? (string) $member->name
|
||||||
|
: (string) $member->email;
|
||||||
|
|
||||||
|
if (trim((string) $member->email) !== '') {
|
||||||
|
$label .= ' <'.$member->email.'>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [(int) $member->getKey() => $label];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptAcquire) {
|
||||||
|
app(OnboardingLockService::class)->acquire($this->session, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->refresh();
|
||||||
|
$this->session->loadMissing(['lockedBy']);
|
||||||
|
|
||||||
|
$this->hasSessionLock = (int) ($this->session->locked_by_user_id ?? 0) === (int) $user->getKey()
|
||||||
|
&& $this->session->locked_until?->isFuture();
|
||||||
|
|
||||||
|
$this->sessionLockedByOther = $this->isLockedByOther($this->session, $user);
|
||||||
|
|
||||||
|
if ($this->sessionLockedByOther) {
|
||||||
|
$lockedBy = $this->session->lockedBy;
|
||||||
|
$this->sessionLockedByLabel = $lockedBy instanceof User
|
||||||
|
? (trim((string) $lockedBy->name) !== '' ? (string) $lockedBy->name : (string) $lockedBy->email)
|
||||||
|
: 'another user';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionLockedUntil = $this->session->locked_until?->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureLockForMutation(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$acquired = app(OnboardingLockService::class)->acquire($this->session, $user);
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
if ($acquired) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Session is locked')
|
||||||
|
->body('Another user is currently editing onboarding. Take over the lock to make changes.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLockedByOther(OnboardingSession $session, User $user): bool
|
||||||
|
{
|
||||||
|
if ($session->locked_by_user_id === null || $session->locked_until === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session->locked_until->isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $session->locked_by_user_id !== (int) $user->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function takeoverSession(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->canManageTenant) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousLockHolderId = $this->session->locked_by_user_id;
|
||||||
|
|
||||||
|
app(OnboardingLockService::class)->takeover($this->session, $user);
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'onboarding.takeover',
|
||||||
|
context: [
|
||||||
|
'onboarding_session_id' => (int) $this->session->getKey(),
|
||||||
|
'previous_locked_by_user_id' => is_int($previousLockHolderId) ? $previousLockHolderId : null,
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'onboarding_session',
|
||||||
|
resourceId: (string) $this->session->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Lock taken over')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handoffSession(int $assignedToUserId): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->canManageTenant) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ensureLockForMutation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignee = $tenant->users()->whereKey($assignedToUserId)->first();
|
||||||
|
if (! $assignee instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Assignee not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->update(['assigned_to_user_id' => (int) $assignee->getKey()]);
|
||||||
|
|
||||||
|
app(OnboardingLockService::class)->release($this->session, $user);
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'onboarding.handoff',
|
||||||
|
context: [
|
||||||
|
'onboarding_session_id' => (int) $this->session->getKey(),
|
||||||
|
'assigned_to_user_id' => (int) $assignee->getKey(),
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'onboarding_session',
|
||||||
|
resourceId: (string) $this->session->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Onboarding handed off')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseSessionLock(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(OnboardingLockService::class)->release($this->session, $user);
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Lock released')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOfferLegacyCredentialMigration(): bool
|
||||||
|
{
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $this->tenantHasLegacyCredentials($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionId = $this->session->provider_connection_id;
|
||||||
|
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->with('credential')
|
||||||
|
->whereKey($connectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$credential = $connection->credential;
|
||||||
|
|
||||||
|
if ($credential === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($credential->type !== 'client_secret') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $credential->payload;
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = trim((string) Arr::get($payload, 'client_id'));
|
||||||
|
$clientSecret = trim((string) Arr::get($payload, 'client_secret'));
|
||||||
|
|
||||||
|
return $clientId === '' || $clientSecret === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function migrateLegacyCredentials(): void
|
||||||
|
{
|
||||||
|
if (! $this->canManageProviderConnections) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ensureLockForMutation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$connectionId = $this->session->provider_connection_id;
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Select a provider connection first')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($connectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Selected provider connection not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = app(LegacyTenantCredentialMigrator::class)->migrate($tenant, $connection);
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($outcome['migrated'] ? 'Credentials migrated' : 'Migration not needed')
|
||||||
|
->body($outcome['message'])
|
||||||
|
->color($outcome['migrated'] ? 'success' : 'gray')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, label: string}>
|
||||||
|
*/
|
||||||
|
public function providerConnections(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderBy('provider')
|
||||||
|
->orderBy('display_name')
|
||||||
|
->get()
|
||||||
|
->map(function (ProviderConnection $connection): array {
|
||||||
|
$entraName = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name');
|
||||||
|
$entraSuffix = is_string($entraName) && trim($entraName) !== '' ? ' — '.trim($entraName) : '';
|
||||||
|
|
||||||
|
$label = ($connection->display_name ?: ucfirst($connection->provider))
|
||||||
|
.$entraSuffix
|
||||||
|
.($connection->is_default ? ' (default)' : '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $connection->getKey(),
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectedProviderConnectionId(): void
|
||||||
|
{
|
||||||
|
if (! $this->canStartProviderTasks) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ensureLockForMutation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_int($this->selectedProviderConnectionId)) {
|
||||||
|
$this->session->update(['provider_connection_id' => null]);
|
||||||
|
$this->session->refresh();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($this->selectedProviderConnectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->update([
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
]);
|
||||||
|
$this->session->refresh();
|
||||||
|
|
||||||
|
$this->refreshCollaborationState(attemptAcquire: false);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Provider connection selected')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{task_type: string, title: string, step: int, prerequisites: array<int, string>}>
|
||||||
|
*/
|
||||||
|
public function planTasks(): array
|
||||||
|
{
|
||||||
|
return OnboardingTaskCatalog::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function latestEvidenceStatusByTaskType(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('task_type', OnboardingTaskType::all())
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byTask = [];
|
||||||
|
|
||||||
|
foreach ($evidence as $row) {
|
||||||
|
if (! isset($byTask[$row->task_type])) {
|
||||||
|
$byTask[$row->task_type] = $row->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startVerifyPermissions(): void
|
||||||
|
{
|
||||||
|
if (! $this->canStartProviderTasks) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No onboarding session')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ensureLockForMutation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionId = $this->session->provider_connection_id;
|
||||||
|
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Select a provider connection first')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($connectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Selected provider connection not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->session->current_step < 4) {
|
||||||
|
$this->session->update(['current_step' => 4]);
|
||||||
|
$this->session->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskType = OnboardingTaskType::VerifyPermissions;
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: $taskType,
|
||||||
|
identityInputs: [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'onboarding_session_id' => (int) $this->session->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->verifyPermissionsRunUrl = OperationRunLinks::view($run, $tenant);
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
OnboardingVerifyPermissionsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verify permissions queued')
|
||||||
|
->body('Run queued. Use the link below to monitor progress.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verify permissions already queued')
|
||||||
|
->body('A run is already queued or running. Use the link below to monitor progress.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startConsentStatus(): void
|
||||||
|
{
|
||||||
|
if (! $this->canStartProviderTasks) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->session instanceof OnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ensureLockForMutation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->session->current_step < 4) {
|
||||||
|
$this->session->update(['current_step' => 4]);
|
||||||
|
$this->session->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! OnboardingTaskCatalog::prerequisitesMet(
|
||||||
|
taskType: OnboardingTaskType::ConsentStatus,
|
||||||
|
latestEvidenceStatusByTaskType: $this->latestEvidenceStatusByTaskType(),
|
||||||
|
)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Prerequisites not met')
|
||||||
|
->body('Run “Verify permissions” first.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionId = $this->session->provider_connection_id;
|
||||||
|
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Select a provider connection first')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereKey($connectionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Selected provider connection not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskType = OnboardingTaskType::ConsentStatus;
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: $taskType,
|
||||||
|
identityInputs: [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'onboarding_session_id' => (int) $this->session->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->consentStatusRunUrl = OperationRunLinks::view($run, $tenant);
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
OnboardingConsentStatusJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Consent status queued')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Consent status already queued')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createProviderConnectionUrl(): string
|
||||||
|
{
|
||||||
|
return CreateProviderConnection::getUrl(tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,30 +21,14 @@ public static function getLabel(): string
|
|||||||
|
|
||||||
public static function canView(): bool
|
public static function canView(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
|
||||||
|
|
||||||
if ($tenantIds->isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
|
||||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
|
|||||||
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal file
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPermission;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Jobs\TenantOnboardingVerifyJob;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
|
use App\Services\TenantOnboardingAuditService;
|
||||||
|
use App\Services\TenantOnboardingSessionService;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Providers\CredentialManager;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Filament\Forms\Components\Checkbox;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Wizard;
|
||||||
|
use Filament\Schemas\Components\Wizard\Step;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantOnboardingWizard extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'tenant-onboarding';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Tenant onboarding';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.tenant-onboarding-wizard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public ?string $sessionId = null;
|
||||||
|
|
||||||
|
public ?int $tenantId = null;
|
||||||
|
|
||||||
|
public ?string $currentStep = null;
|
||||||
|
|
||||||
|
public ?int $verificationRunId = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenantFromRequest();
|
||||||
|
|
||||||
|
$sessionService = app(TenantOnboardingSessionService::class);
|
||||||
|
|
||||||
|
if (filled(request()->query('session'))) {
|
||||||
|
$session = $sessionService->resumeById($user, (string) request()->query('session'));
|
||||||
|
} else {
|
||||||
|
$session = $sessionService->startOrResume($user, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionId = (string) $session->getKey();
|
||||||
|
$this->tenantId = $session->tenant_id;
|
||||||
|
$this->currentStep = (string) $session->current_step;
|
||||||
|
|
||||||
|
$this->data = array_merge($this->data, $session->payload ?? []);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$this->data = array_merge($this->data, [
|
||||||
|
'name' => $tenant->name,
|
||||||
|
'tenant_id' => $tenant->tenant_id,
|
||||||
|
'domain' => $tenant->domain,
|
||||||
|
'environment' => $tenant->environment,
|
||||||
|
'app_client_id' => $tenant->app_client_id,
|
||||||
|
'app_certificate_thumbprint' => $tenant->app_certificate_thumbprint,
|
||||||
|
'app_notes' => $tenant->app_notes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->form->fill($this->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueueVerification(): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
|
$this->requireCapability(Capabilities::PROVIDER_RUN);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->requireTenant();
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'tenant.rbac.verify',
|
||||||
|
identityInputs: [
|
||||||
|
'purpose' => 'tenant_rbac_verify',
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'operation' => [
|
||||||
|
'type' => 'tenant.rbac.verify',
|
||||||
|
],
|
||||||
|
'target_scope' => [
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->verificationRunId = (int) $run->getKey();
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
$runs->dispatchOrFail($run, function (OperationRun $run) use ($tenant, $user): void {
|
||||||
|
TenantOnboardingVerifyJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Notification::make()->title('Verification queued')->success()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title('Verification already in progress')->info()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueueConnectionCheck(): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
|
$this->requireCapability(Capabilities::PROVIDER_RUN);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->requireTenant();
|
||||||
|
|
||||||
|
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||||
|
|
||||||
|
/** @var ProviderOperationStartGate $gate */
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $user, $connection): void {
|
||||||
|
ProviderConnectionHealthCheckJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()->title('Scope busy')->warning()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()->title('Connection check already queued')->info()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title('Connection check queued')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('data')
|
||||||
|
->components([
|
||||||
|
Wizard::make($this->getSteps())
|
||||||
|
->startOnStep(fn (): int => $this->getStartStep())
|
||||||
|
->submitAction('')
|
||||||
|
->cancelAction(''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Step>
|
||||||
|
*/
|
||||||
|
private function getSteps(): array
|
||||||
|
{
|
||||||
|
$steps = [
|
||||||
|
Step::make('Welcome')
|
||||||
|
->id('welcome')
|
||||||
|
->description('Requirements and what to expect.')
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\Placeholder::make('welcome_copy')
|
||||||
|
->label('')
|
||||||
|
->content('This wizard will create or update a tenant record without making any outbound calls. You can resume at any time.'),
|
||||||
|
])
|
||||||
|
->afterValidation(fn (): mixed => $this->persistStep('tenant_details')),
|
||||||
|
|
||||||
|
Step::make('Tenant Details')
|
||||||
|
->id('tenant_details')
|
||||||
|
->description('Basic tenant metadata')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Display name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Select::make('environment')
|
||||||
|
->options([
|
||||||
|
'prod' => 'PROD',
|
||||||
|
'dev' => 'DEV',
|
||||||
|
'staging' => 'STAGING',
|
||||||
|
'other' => 'Other',
|
||||||
|
])
|
||||||
|
->default('other')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('tenant_id')
|
||||||
|
->label('Tenant ID (GUID)')
|
||||||
|
->required()
|
||||||
|
->rule('uuid')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('domain')
|
||||||
|
->label('Primary domain')
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->afterValidation(fn (): mixed => $this->handleTenantDetailsCompleted()),
|
||||||
|
];
|
||||||
|
|
||||||
|
$steps = array_merge($steps, $this->credentialsRequired()
|
||||||
|
? [$this->credentialsStep()]
|
||||||
|
: []);
|
||||||
|
|
||||||
|
$steps[] = Step::make('Admin Consent & Permissions')
|
||||||
|
->id('permissions')
|
||||||
|
->description('Grant permissions and verify access')
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\Placeholder::make('permissions_copy')
|
||||||
|
->label('')
|
||||||
|
->content('Next, you will grant admin consent and verify permissions. (Verification runs are implemented in the next phase.)'),
|
||||||
|
])
|
||||||
|
->afterValidation(fn (): mixed => $this->persistStep('verification'));
|
||||||
|
|
||||||
|
$steps[] = Step::make('Verification / First Run')
|
||||||
|
->id('verification')
|
||||||
|
->description('Finish setup and validate readiness')
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\Placeholder::make('verification_copy')
|
||||||
|
->label('')
|
||||||
|
->content('Verification checks are enqueue-only and will appear here once implemented.'),
|
||||||
|
])
|
||||||
|
->afterValidation(fn (): mixed => $this->persistStep('verification'));
|
||||||
|
|
||||||
|
return $steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function credentialsStep(): Step
|
||||||
|
{
|
||||||
|
return Step::make('App / Credentials')
|
||||||
|
->id('credentials')
|
||||||
|
->description('Set credentials (if required)')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('app_client_id')
|
||||||
|
->label('App Client ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('app_client_secret')
|
||||||
|
->label('App Client Secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('app_certificate_thumbprint')
|
||||||
|
->label('Certificate thumbprint')
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('app_notes')
|
||||||
|
->label('Notes')
|
||||||
|
->rows(3),
|
||||||
|
Checkbox::make('acknowledge_credentials')
|
||||||
|
->label('I understand this will store credentials encrypted and they cannot be shown again.')
|
||||||
|
->accepted()
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->afterValidation(fn (): mixed => $this->handleCredentialsCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleTenantDetailsCompleted(): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||||
|
|
||||||
|
$tenant = $this->upsertTenantFromData();
|
||||||
|
|
||||||
|
$this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||||
|
|
||||||
|
$this->tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$nextStep = $this->credentialsRequired() ? 'credentials' : 'permissions';
|
||||||
|
|
||||||
|
$this->persistStep($nextStep, $tenant);
|
||||||
|
|
||||||
|
Notification::make()->title('Tenant details saved')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleCredentialsCompleted(): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||||
|
|
||||||
|
$tenant = $this->requireTenant();
|
||||||
|
|
||||||
|
$secret = (string) ($this->data['app_client_secret'] ?? '');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'app_client_id' => $this->data['app_client_id'] ?? null,
|
||||||
|
'app_certificate_thumbprint' => $this->data['app_certificate_thumbprint'] ?? null,
|
||||||
|
'app_notes' => $this->data['app_notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled($secret)) {
|
||||||
|
$tenant->forceFill(['app_client_secret' => $secret]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->save();
|
||||||
|
|
||||||
|
if (filled($secret) && filled($tenant->app_client_id) && filled($tenant->tenant_id)) {
|
||||||
|
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||||
|
|
||||||
|
app(CredentialManager::class)->upsertClientSecretCredential(
|
||||||
|
connection: $connection,
|
||||||
|
clientId: (string) $tenant->app_client_id,
|
||||||
|
clientSecret: (string) $secret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($secret)) {
|
||||||
|
$actor = auth()->user();
|
||||||
|
|
||||||
|
app(TenantOnboardingAuditService::class)->credentialsUpdated(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor instanceof User ? $actor : null,
|
||||||
|
context: [
|
||||||
|
'app_client_id_set' => filled($tenant->app_client_id),
|
||||||
|
'app_client_secret_set' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data['app_client_secret'] = null;
|
||||||
|
$this->form->fill($this->data);
|
||||||
|
|
||||||
|
$this->persistStep('permissions', $tenant);
|
||||||
|
|
||||||
|
Notification::make()->title('Credentials saved')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistStep(string $currentStep, ?Tenant $tenant = null): void
|
||||||
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $this->requireSession();
|
||||||
|
|
||||||
|
$service = app(TenantOnboardingSessionService::class);
|
||||||
|
|
||||||
|
$updated = $service->persistProgress(
|
||||||
|
session: $session,
|
||||||
|
currentStep: $currentStep,
|
||||||
|
payload: $this->data,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->sessionId = (string) $updated->getKey();
|
||||||
|
$this->tenantId = $updated->tenant_id;
|
||||||
|
$this->currentStep = (string) $updated->current_step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB-only: uses config + stored tenant_permissions.
|
||||||
|
*
|
||||||
|
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>,status:string}>
|
||||||
|
*/
|
||||||
|
public function permissionRows(): array
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = config('intune_permissions.permissions', []);
|
||||||
|
$required = is_array($required) ? $required : [];
|
||||||
|
|
||||||
|
$granted = TenantPermission::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->get()
|
||||||
|
->keyBy('permission_key');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($required as $permission) {
|
||||||
|
if (! is_array($permission)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = (string) ($permission['key'] ?? '');
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $granted->get($key);
|
||||||
|
$status = $stored instanceof TenantPermission
|
||||||
|
? (string) $stored->status
|
||||||
|
: 'missing';
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'type' => (string) ($permission['type'] ?? 'application'),
|
||||||
|
'description' => isset($permission['description']) && is_string($permission['description']) ? $permission['description'] : null,
|
||||||
|
'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [],
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestVerificationRunStatus(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $this->tenantId)
|
||||||
|
->where('type', 'tenant.rbac.verify')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $run->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestConnectionCheckRunStatus(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = $tenant->providerConnections()
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('entra_tenant_id', $tenant->tenant_id)
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->where('context->provider_connection_id', (int) $connection->getKey())
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $run->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReadyToCompleteOnboarding(): bool
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = $tenant->providerConnections()
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('entra_tenant_id', $tenant->tenant_id)
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$connectionOk = $connection instanceof ProviderConnection
|
||||||
|
&& (string) $connection->health_status === 'ok'
|
||||||
|
&& (string) $connection->status === 'connected';
|
||||||
|
|
||||||
|
$permissionsOk = collect($this->permissionRows())
|
||||||
|
->every(fn (array $row): bool => (string) ($row['status'] ?? 'missing') === 'granted');
|
||||||
|
|
||||||
|
$verifyRunOk = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'tenant.rbac.verify')
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', 'succeeded')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return $connectionOk && $permissionsOk && $verifyRunOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDefaultMicrosoftProviderConnection(Tenant $tenant): ProviderConnection
|
||||||
|
{
|
||||||
|
$existing = $tenant->providerConnections()
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('entra_tenant_id', $tenant->tenant_id)
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof ProviderConnection) {
|
||||||
|
if (! $existing->is_default) {
|
||||||
|
$existing->makeDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnection::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'Microsoft Graph',
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'needs_consent',
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'scopes_granted' => [],
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertTenantFromData(): Tenant
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantGuid = Str::lower((string) ($this->data['tenant_id'] ?? ''));
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->first();
|
||||||
|
$isNewTenant = ! $tenant instanceof Tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$tenant = new Tenant();
|
||||||
|
$tenant->forceFill([
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'name' => $this->data['name'] ?? null,
|
||||||
|
'tenant_id' => $tenantGuid,
|
||||||
|
'domain' => $this->data['domain'] ?? null,
|
||||||
|
'environment' => $this->data['environment'] ?? 'other',
|
||||||
|
'onboarding_status' => 'in_progress',
|
||||||
|
'onboarding_completed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tenant->save();
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyMember = $user->tenants()->whereKey($tenant->getKey())->exists();
|
||||||
|
|
||||||
|
if (! $alreadyMember) {
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => [
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'created_by_user_id' => $user->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNewTenant && ! $alreadyMember) {
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'tenant_membership.bootstrap_assign',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenantFromRequest();
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For creating a new tenant (not yet in scope), require that the user can manage at least one tenant.
|
||||||
|
$canManageAny = $user->tenantMemberships()
|
||||||
|
->pluck('role')
|
||||||
|
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||||
|
|
||||||
|
if (! $canManageAny) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantFromRequest(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantExternalId = request()->query('tenant');
|
||||||
|
|
||||||
|
if (! is_string($tenantExternalId) || blank($tenantExternalId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('external_id', $tenantExternalId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function credentialsRequired(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('tenantpilot.onboarding.credentials_required', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStartStep(): int
|
||||||
|
{
|
||||||
|
$session = $this->requireSession();
|
||||||
|
|
||||||
|
$keys = $this->getStepKeys();
|
||||||
|
$index = array_search((string) $session->current_step, $keys, true);
|
||||||
|
|
||||||
|
if ($index === false) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function getStepKeys(): array
|
||||||
|
{
|
||||||
|
$keys = ['welcome', 'tenant_details'];
|
||||||
|
|
||||||
|
if ($this->credentialsRequired()) {
|
||||||
|
$keys[] = 'credentials';
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys[] = 'permissions';
|
||||||
|
$keys[] = 'verification';
|
||||||
|
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireSession(): TenantOnboardingSession
|
||||||
|
{
|
||||||
|
if (! filled($this->sessionId)) {
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionId = (string) app(TenantOnboardingSessionService::class)->startOrResume($user)->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOnboardingSession::query()->whereKey($this->sessionId)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireTenant(): Tenant
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
abort(400, 'Tenant not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()->whereKey($this->tenantId)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantHasClientSecret(): bool
|
||||||
|
{
|
||||||
|
if (! $this->tenantId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant && filled($tenant->getRawOriginal('app_client_secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRunProviderOperations(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenantForAuthorization();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& app(CapabilityResolver::class)->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireCapability(string $capability): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenantForAuthorization();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! app(CapabilityResolver::class)->can($user, $tenant, $capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantForAuthorization(): ?Tenant
|
||||||
|
{
|
||||||
|
if ($this->tenantId) {
|
||||||
|
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveTenantFromRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
@ -116,6 +117,18 @@ protected function getHeaderActions(): array
|
|||||||
->visible(false),
|
->visible(false),
|
||||||
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('resume_onboarding')
|
||||||
|
->label('Resume onboarding')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => TenantOnboardingWizard::getUrl(tenant: Tenant::current()))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
|
->tooltip('You do not have permission to view provider onboarding.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('view_last_check_run')
|
Action::make('view_last_check_run')
|
||||||
->label('View last check run')
|
->label('View last check run')
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToWorkspace;
|
||||||
use App\Filament\Resources\TenantResource\Pages;
|
use App\Filament\Resources\TenantResource\Pages;
|
||||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
@ -9,8 +10,10 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -30,6 +33,7 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -53,6 +57,8 @@
|
|||||||
|
|
||||||
class TenantResource extends Resource
|
class TenantResource extends Resource
|
||||||
{
|
{
|
||||||
|
use ScopesGlobalSearchToWorkspace;
|
||||||
|
|
||||||
// ... [Properties Omitted for Brevity] ...
|
// ... [Properties Omitted for Brevity] ...
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
@ -60,7 +66,9 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Managed tenants';
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
@ -70,7 +78,7 @@ public static function canCreate(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::userCanManageAnyTenant($user);
|
return static::userCanManageTenantsInCurrentWorkspace($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
@ -81,11 +89,12 @@ public static function canEdit(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $record instanceof Tenant
|
return $record instanceof Tenant
|
||||||
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
&& $workspace instanceof Workspace
|
||||||
|
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
||||||
|
&& static::userCanManageTenantsInCurrentWorkspace($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
@ -96,11 +105,12 @@ public static function canDelete(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $record instanceof Tenant
|
return $record instanceof Tenant
|
||||||
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
&& $workspace instanceof Workspace
|
||||||
|
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
||||||
|
&& static::userCanDeleteTenantsInCurrentWorkspace($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
@ -111,21 +121,49 @@ public static function canDeleteAny(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::userCanDeleteAnyTenant($user);
|
return static::userCanDeleteTenantsInCurrentWorkspace($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanManageAnyTenant(User $user): bool
|
private static function userCanDeleteTenantsInCurrentWorkspace(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->tenantMemberships()
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
->pluck('role')
|
|
||||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanDeleteAnyTenant(User $user): bool
|
private static function userCanManageTenantsInCurrentWorkspace(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->tenantMemberships()
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
->pluck('role')
|
|
||||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
|
||||||
|
{
|
||||||
|
/** @var WorkspaceContext $context */
|
||||||
|
$context = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
$workspace = $context->resolveInitialWorkspaceFor($user);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context->isMember($user, $workspace) ? $workspace : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -172,20 +210,21 @@ public static function form(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
// ... [Query Omitted - No Change] ...
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenants()
|
$workspace = static::resolveCurrentWorkspaceFor($user);
|
||||||
->withTrashed()
|
|
||||||
->pluck('tenants.id');
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->whereIn('id', $tenantIds)
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->withCount('policies')
|
->withCount('policies')
|
||||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||||
}
|
}
|
||||||
@ -260,6 +299,11 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('open')
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW),
|
||||||
Actions\Action::make('view')
|
Actions\Action::make('view')
|
||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
@ -390,7 +434,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
@ -410,7 +454,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_RESTORE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,7 +471,7 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
@ -497,7 +541,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,7 +564,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
@ -543,7 +587,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,7 +620,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
@ -873,8 +917,12 @@ public static function rbacAction(): Actions\Action
|
|||||||
->noSearchResultsMessage('No security groups found')
|
->noSearchResultsMessage('No security groups found')
|
||||||
->loadingMessage('Searching groups...'),
|
->loadingMessage('Searching groups...'),
|
||||||
])
|
])
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (?Tenant $record): bool => (bool) $record?->isActive())
|
||||||
->disabled(function (Tenant $record): bool {
|
->disabled(function (?Tenant $record): bool {
|
||||||
|
if ($record === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
|
|||||||
@ -2,14 +2,53 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
class CreateTenant extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent setting legacy tenant credentials during create.
|
||||||
|
* Credential setup should happen via the onboarding flow.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
unset(
|
||||||
|
$data['app_client_id'],
|
||||||
|
$data['app_client_secret'],
|
||||||
|
$data['app_certificate_thumbprint'],
|
||||||
|
$data['app_notes'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceContext $context */
|
||||||
|
$context = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
$workspace = $context->resolveInitialWorkspaceFor($user, request());
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['workspace_id'] = (int) $workspace->getKey();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -22,4 +61,9 @@ protected function afterCreate(): void
|
|||||||
$this->record->getKey() => ['role' => 'owner'],
|
$this->record->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return TenantOnboardingWizard::getUrl(tenant: $this->record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,8 +28,8 @@ protected function getHeaderActions(): array
|
|||||||
$record->delete();
|
$record->delete();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
||||||
->tooltip('You do not have permission to archive tenants.')
|
->tooltip('You do not have permission to archive managed tenants.')
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -10,12 +11,26 @@ class ListTenants extends ListRecords
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
parent::mount();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($user instanceof User && ! $user->tenantMemberships()->exists()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
Actions\Action::make('add_managed_tenant')
|
||||||
|
->label('Add managed tenant')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->url('/admin/managed-tenants/onboarding')
|
||||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
->disabled(fn (): bool => ! TenantResource::canCreate())
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to add managed tenants.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -30,6 +31,25 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('open_managed_tenant')
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('resume_onboarding')
|
||||||
|
->label('Resume onboarding')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl(tenant: $record))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
|
->tooltip('You do not have permission to view provider onboarding.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
|
|||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php
Normal file
142
app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingConnectionDiagnosticsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($connection->status ?? 'unknown');
|
||||||
|
$health = (string) ($connection->health_status ?? 'unknown');
|
||||||
|
|
||||||
|
$evidenceStatus = 'unknown';
|
||||||
|
$reasonCode = null;
|
||||||
|
$message = 'No health check data available yet.';
|
||||||
|
|
||||||
|
if ($status !== 'connected') {
|
||||||
|
$evidenceStatus = 'blocked';
|
||||||
|
$reasonCode = 'provider.needs_consent';
|
||||||
|
$message = 'Provider connection is not connected. Admin consent may be required.';
|
||||||
|
} elseif ($health === 'healthy') {
|
||||||
|
$evidenceStatus = 'ok';
|
||||||
|
$message = 'Provider connection appears healthy.';
|
||||||
|
} elseif ($health === 'unhealthy') {
|
||||||
|
$evidenceStatus = 'error';
|
||||||
|
$reasonCode = is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : 'provider.outage';
|
||||||
|
$message = is_string($connection->last_error_message) && trim($connection->last_error_message) !== ''
|
||||||
|
? $connection->last_error_message
|
||||||
|
: 'Provider connection health check indicates an error.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::ConnectionDiagnostics,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'status' => $status,
|
||||||
|
'health_status' => $health,
|
||||||
|
'last_health_check_at' => $connection->last_health_check_at?->toIso8601String(),
|
||||||
|
'last_error_reason_code' => $connection->last_error_reason_code,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.connection.diagnostics.failed',
|
||||||
|
'reason_code' => $reasonCode ?? 'connection.diagnostics.unknown',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
app/Jobs/Onboarding/OnboardingConsentStatusJob.php
Normal file
134
app/Jobs/Onboarding/OnboardingConsentStatusJob.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingConsentStatusJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OnboardingEvidenceWriter $evidence,
|
||||||
|
OperationRunService $runs,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($connection->status ?? 'unknown');
|
||||||
|
|
||||||
|
$evidenceStatus = match ($status) {
|
||||||
|
'connected' => 'ok',
|
||||||
|
'needs_consent' => 'blocked',
|
||||||
|
default => 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
$message = match ($status) {
|
||||||
|
'connected' => 'Consent appears granted (connection is connected).',
|
||||||
|
'needs_consent' => 'Consent is missing or credentials are not authorized yet.',
|
||||||
|
default => 'Unable to determine consent status.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::ConsentStatus,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $status === 'needs_consent' ? 'consent.missing' : null,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'provider_connection_status' => $status,
|
||||||
|
'provider_connection_health_status' => $connection->health_status,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.consent.status.failed',
|
||||||
|
'reason_code' => $status === 'needs_consent' ? 'consent.missing' : 'consent.status.error',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Jobs/Onboarding/OnboardingInitialSyncJob.php
Normal file
125
app/Jobs/Onboarding/OnboardingInitialSyncJob.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingInitialSyncJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connected = (string) ($connection->status ?? 'unknown') === 'connected';
|
||||||
|
|
||||||
|
$evidenceStatus = $connected ? 'ok' : 'blocked';
|
||||||
|
$reasonCode = $connected ? null : 'provider.not_connected';
|
||||||
|
$message = $connected
|
||||||
|
? 'Prerequisites for initial sync look good.'
|
||||||
|
: 'Provider connection is not connected. Resolve consent/credentials first.';
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::InitialSync,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'provider_connection_status' => $connection->status,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.initial_sync.blocked',
|
||||||
|
'reason_code' => $reasonCode ?? 'initial_sync.unknown',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php
Normal file
140
app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingVerifyPermissionsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
TenantPermissionService $permissions,
|
||||||
|
OnboardingEvidenceWriter $evidence,
|
||||||
|
OperationRunService $runs,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For onboarding, we default to a safe, non-live permission comparison.
|
||||||
|
// Live Graph calls can be enabled later as a deliberate UX and contract decision.
|
||||||
|
$result = $permissions->compare($tenant, persist: true, liveCheck: false, useConfiguredStub: true);
|
||||||
|
|
||||||
|
$overall = $result['overall_status'] ?? 'error';
|
||||||
|
|
||||||
|
$evidenceStatus = match ($overall) {
|
||||||
|
'granted' => 'ok',
|
||||||
|
'missing' => 'blocked',
|
||||||
|
default => 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
$message = match ($overall) {
|
||||||
|
'granted' => 'All required permissions appear granted.',
|
||||||
|
'missing' => 'Some required permissions are missing.',
|
||||||
|
default => 'Unable to verify permissions.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::VerifyPermissions,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $overall === 'missing' ? 'permissions.missing' : null,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'overall_status' => $overall,
|
||||||
|
'permissions' => $result['permissions'] ?? [],
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.permissions.verify.failed',
|
||||||
|
'reason_code' => $overall === 'missing' ? 'permissions.missing' : 'permissions.verify.error',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal file
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\TenantOnboardingAuditService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TenantOnboardingVerifyJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
TenantPermissionService $permissions,
|
||||||
|
OperationRunService $runs,
|
||||||
|
TenantOnboardingAuditService $audit,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $permissions->compare(
|
||||||
|
tenant: $tenant,
|
||||||
|
grantedStatuses: null,
|
||||||
|
persist: true,
|
||||||
|
liveCheck: true,
|
||||||
|
useConfiguredStub: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overall = (string) ($result['overall_status'] ?? 'error');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'rbac_last_checked_at' => now(),
|
||||||
|
'rbac_last_warnings' => $overall === 'granted' ? [] : ['permissions_not_granted'],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overall === 'granted') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'onboarding_status' => 'completed',
|
||||||
|
'onboarding_completed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
TenantOnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->update([
|
||||||
|
'status' => 'completed',
|
||||||
|
'current_step' => 'verification',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$audit->onboardingCompleted(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $user,
|
||||||
|
context: [
|
||||||
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'tenant.rbac.verify.not_granted',
|
||||||
|
'message' => 'Permissions are missing or could not be verified.',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,4 +21,9 @@ public function tenant(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Models/OnboardingEvidence.php
Normal file
46
app/Models/OnboardingEvidence.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OnboardingEvidence extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'onboarding_evidence';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'recorded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onboardingSession(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OnboardingSession::class, 'onboarding_session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class, 'operation_run_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'recorded_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Models/OnboardingSession.php
Normal file
47
app/Models/OnboardingSession.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class OnboardingSession extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'current_step' => 'integer',
|
||||||
|
'locked_until' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignedTo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_to_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lockedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'locked_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evidence(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OnboardingEvidence::class, 'onboarding_session_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
@ -26,6 +27,7 @@ class Tenant extends Model implements HasName
|
|||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'app_client_secret' => 'encrypted',
|
'app_client_secret' => 'encrypted',
|
||||||
'is_current' => 'boolean',
|
'is_current' => 'boolean',
|
||||||
|
'onboarding_completed_at' => 'datetime',
|
||||||
'rbac_last_checked_at' => 'datetime',
|
'rbac_last_checked_at' => 'datetime',
|
||||||
'rbac_last_setup_at' => 'datetime',
|
'rbac_last_setup_at' => 'datetime',
|
||||||
'rbac_canary_results' => 'array',
|
'rbac_canary_results' => 'array',
|
||||||
@ -170,6 +172,16 @@ public function memberships(): HasMany
|
|||||||
return $this->hasMany(TenantMembership::class);
|
return $this->hasMany(TenantMembership::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onboardingSessions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TenantOnboardingSession::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function roleMappings(): HasMany
|
public function roleMappings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantRoleMapping::class);
|
return $this->hasMany(TenantRoleMapping::class);
|
||||||
|
|||||||
40
app/Models/TenantOnboardingSession.php
Normal file
40
app/Models/TenantOnboardingSession.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TenantOnboardingSession extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'abandoned_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdByUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Policies/OnboardingEvidencePolicy.php
Normal file
58
app/Policies/OnboardingEvidencePolicy.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class OnboardingEvidencePolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OnboardingEvidence $evidence): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $evidence->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Policies/OnboardingSessionPolicy.php
Normal file
100
app/Policies/OnboardingSessionPolicy.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class OnboardingSessionPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OnboardingSession $session): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, OnboardingSession $session): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,9 @@
|
|||||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action as FilamentAction;
|
||||||
|
use Filament\Actions\BulkAction as FilamentBulkAction;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -84,6 +87,28 @@ public function register(): void
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
if (! FilamentAction::hasMacro('requireCapability')) {
|
||||||
|
FilamentAction::macro('requireCapability', function (string $capability): FilamentAction {
|
||||||
|
UiEnforcement::forAction($this)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability($capability)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! FilamentBulkAction::hasMacro('requireCapability')) {
|
||||||
|
FilamentBulkAction::macro('requireCapability', function (string $capability): FilamentBulkAction {
|
||||||
|
UiEnforcement::forBulkAction($this)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability($capability)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
RateLimiter::for('entra-callback', function (Request $request) {
|
RateLimiter::for('entra-callback', function (Request $request) {
|
||||||
return Limit::perMinute(20)->by((string) $request->ip());
|
return Limit::perMinute(20)->by((string) $request->ip());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Policies\OnboardingEvidencePolicy;
|
||||||
|
use App\Policies\OnboardingSessionPolicy;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
|
use App\Policies\WorkspaceMembershipPolicy;
|
||||||
|
use App\Policies\WorkspacePolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
@ -17,21 +26,38 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
protected $policies = [
|
protected $policies = [
|
||||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||||
|
Workspace::class => WorkspacePolicy::class,
|
||||||
|
WorkspaceMembership::class => WorkspaceMembershipPolicy::class,
|
||||||
|
OnboardingSession::class => OnboardingSessionPolicy::class,
|
||||||
|
OnboardingEvidence::class => OnboardingEvidencePolicy::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->registerPolicies();
|
$this->registerPolicies();
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
||||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool {
|
||||||
return $resolver->can($user, $tenant, $capability);
|
return $workspaceResolver->can($user, $workspace, $capability);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||||
|
Gate::define($capability, function (User $user, Tenant $tenant) use ($tenantResolver, $capability): bool {
|
||||||
|
return $tenantResolver->can($user, $tenant, $capability);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (Capabilities::all() as $capability) {
|
foreach (Capabilities::all() as $capability) {
|
||||||
|
if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) {
|
||||||
|
$defineWorkspaceCapability($capability);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$defineTenantCapability($capability);
|
$defineTenantCapability($capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,25 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
|
use App\Filament\Pages\ManagedTenants\ArchivedStatus;
|
||||||
|
use App\Filament\Pages\ManagedTenants\Current;
|
||||||
|
use App\Filament\Pages\ManagedTenants\EditManagedTenant;
|
||||||
|
use App\Filament\Pages\ManagedTenants\Index as ManagedTenantsIndex;
|
||||||
|
use App\Filament\Pages\ManagedTenants\Onboarding;
|
||||||
|
use App\Filament\Pages\ManagedTenants\ViewManagedTenant;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
use App\Filament\Pages\TenantOnboardingWizard;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ManagedTenants\ManagedTenantContext;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
|
use App\Support\Middleware\EnsureFilamentTenantSelected;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -23,6 +37,7 @@
|
|||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
@ -38,13 +53,68 @@ public function panel(Panel $panel): Panel
|
|||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
|
|
||||||
|
TenantOnboardingWizard::registerRoutes($panel);
|
||||||
|
|
||||||
|
if ($panel->hasTenantRegistration()) {
|
||||||
|
$tenantRegistrationPage = $panel->getTenantRegistrationPage();
|
||||||
|
|
||||||
|
Route::get($tenantRegistrationPage::getRoutePath($panel), $tenantRegistrationPage)
|
||||||
|
->middleware($tenantRegistrationPage::getRouteMiddleware($panel))
|
||||||
|
->withoutMiddleware($tenantRegistrationPage::getWithoutRouteMiddleware($panel))
|
||||||
|
->name('tenant.registration');
|
||||||
|
}
|
||||||
|
|
||||||
|
ManagedTenantsIndex::registerRoutes($panel);
|
||||||
|
Onboarding::registerRoutes($panel);
|
||||||
|
Current::registerRoutes($panel);
|
||||||
|
ArchivedStatus::registerRoutes($panel);
|
||||||
|
|
||||||
|
ViewManagedTenant::registerRoutes($panel);
|
||||||
|
EditManagedTenant::registerRoutes($panel);
|
||||||
|
|
||||||
|
Route::get('managed-tenants/{managedTenant}/open', function (string $managedTenant) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenantMemberships()->exists()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$managedTenant = Tenant::withTrashed()->findOrFail($managedTenant);
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $managedTenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $managedTenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($managedTenant->isActive()) {
|
||||||
|
ManagedTenantContext::setCurrentTenant($managedTenant);
|
||||||
|
ManagedTenantContext::clearArchivedTenant();
|
||||||
|
|
||||||
|
return redirect('/admin/managed-tenants/current');
|
||||||
|
}
|
||||||
|
|
||||||
|
ManagedTenantContext::setArchivedTenant($managedTenant);
|
||||||
|
|
||||||
|
return redirect('/admin/managed-tenants/archived');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix('t')
|
->tenantRoutePrefix('t')
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
->searchableTenantMenu()
|
->searchableTenantMenu()
|
||||||
->tenantRegistration(RegisterTenant::class)
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
@ -70,6 +140,24 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
|
->userMenuItems([
|
||||||
|
Action::make('switch-workspace')
|
||||||
|
->label('Switch workspace')
|
||||||
|
->icon('heroicon-o-squares-2x2')
|
||||||
|
->url('/admin/choose-workspace')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->count() > 1;
|
||||||
|
})
|
||||||
|
->sort(0),
|
||||||
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
@ -79,12 +167,14 @@ public function panel(Panel $panel): Panel
|
|||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
|
EnsureFilamentTenantSelected::class,
|
||||||
DenyNonMemberTenantAccess::class,
|
DenyNonMemberTenantAccess::class,
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
])
|
])
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,13 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -42,6 +49,12 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -62,6 +75,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -79,6 +94,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Readonly->value => [
|
TenantRole::Readonly->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Services/Onboarding/LegacyTenantCredentialMigrator.php
Normal file
68
app/Services/Onboarding/LegacyTenantCredentialMigrator.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\CredentialManager;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class LegacyTenantCredentialMigrator
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CredentialManager $credentials) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{migrated: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function migrate(Tenant $tenant, ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Provider connection does not belong to the tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = trim((string) ($tenant->app_client_id ?? ''));
|
||||||
|
$clientSecret = trim((string) ($tenant->app_client_secret ?? ''));
|
||||||
|
|
||||||
|
if ($clientId === '' || $clientSecret === '') {
|
||||||
|
return [
|
||||||
|
'migrated' => false,
|
||||||
|
'message' => 'No legacy tenant credentials found to migrate.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $connection->credential;
|
||||||
|
|
||||||
|
if ($existing instanceof ProviderCredential) {
|
||||||
|
if ($existing->type !== 'client_secret') {
|
||||||
|
throw new RuntimeException('Provider connection has unsupported credential type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $existing->payload;
|
||||||
|
$existingClientId = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_id'));
|
||||||
|
$existingClientSecret = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_secret'));
|
||||||
|
|
||||||
|
if ($existingClientId !== '' && $existingClientSecret !== '') {
|
||||||
|
return [
|
||||||
|
'migrated' => false,
|
||||||
|
'message' => 'Provider credentials already exist for this connection.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->credentials->upsertClientSecretCredential(
|
||||||
|
connection: $connection,
|
||||||
|
clientId: $clientId,
|
||||||
|
clientSecret: $clientSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'migrated' => true,
|
||||||
|
'message' => 'Legacy tenant credentials migrated to the provider connection.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Services/Onboarding/OnboardingEvidenceWriter.php
Normal file
94
app/Services/Onboarding/OnboardingEvidenceWriter.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
|
||||||
|
class OnboardingEvidenceWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function record(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $taskType,
|
||||||
|
string $status,
|
||||||
|
?string $reasonCode = null,
|
||||||
|
?string $message = null,
|
||||||
|
array $payload = [],
|
||||||
|
?OnboardingSession $session = null,
|
||||||
|
?ProviderConnection $providerConnection = null,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
?User $recordedBy = null,
|
||||||
|
): OnboardingEvidence {
|
||||||
|
$reasonCode = $reasonCode === null ? null : RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
$message = $message === null ? null : RunFailureSanitizer::sanitizeMessage($message);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $payload */
|
||||||
|
$payload = $this->sanitizePayload($payload);
|
||||||
|
|
||||||
|
return OnboardingEvidence::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'onboarding_session_id' => $session?->getKey(),
|
||||||
|
'provider_connection_id' => $providerConnection?->getKey(),
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'status' => $status,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
'payload' => $payload,
|
||||||
|
'operation_run_id' => $operationRun?->getKey(),
|
||||||
|
'recorded_at' => now(),
|
||||||
|
'recorded_by_user_id' => $recordedBy?->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sanitizePayload(array $payload): array
|
||||||
|
{
|
||||||
|
$redactedKeys = ['access_token', 'refresh_token', 'client_secret', 'password', 'authorization', 'bearer'];
|
||||||
|
|
||||||
|
$sanitize = function (mixed $value) use (&$sanitize, $redactedKeys): mixed {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($value as $k => $v) {
|
||||||
|
$key = is_string($k) ? strtolower($k) : null;
|
||||||
|
|
||||||
|
if ($key !== null) {
|
||||||
|
foreach ($redactedKeys as $needle) {
|
||||||
|
if (str_contains($key, $needle)) {
|
||||||
|
$out[$k] = '[REDACTED]';
|
||||||
|
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$k] = $sanitize($v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return RunFailureSanitizer::sanitizeMessage($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $sanitized */
|
||||||
|
$sanitized = $sanitize($payload);
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Services/Onboarding/OnboardingLockService.php
Normal file
89
app/Services/Onboarding/OnboardingLockService.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnboardingLockService
|
||||||
|
{
|
||||||
|
public function acquire(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ($this->isLockedByOther($session, $user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => $user->getKey(),
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renew(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function release(OnboardingSession $session, User $user): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => null,
|
||||||
|
'locked_until' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function takeover(OnboardingSession $session, User $newOwner, int $ttlSeconds = 600): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($session, $newOwner, $ttlSeconds): void {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => $newOwner->getKey(),
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLockedByOther(OnboardingSession $session, User $user): bool
|
||||||
|
{
|
||||||
|
if ($session->locked_by_user_id === null || $session->locked_until === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session->locked_until->isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $session->locked_by_user_id !== (int) $user->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Services/TenantOnboardingAuditService.php
Normal file
68
app/Services/TenantOnboardingAuditService.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Support\Audit\AuditActions;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class TenantOnboardingAuditService
|
||||||
|
{
|
||||||
|
public function __construct(public AuditLogger $auditLogger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function credentialsUpdated(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
|
||||||
|
{
|
||||||
|
$context = $this->sanitizeContext($context);
|
||||||
|
|
||||||
|
return $this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActions::TENANT_ONBOARDING_CREDENTIALS_UPDATED,
|
||||||
|
context: $context,
|
||||||
|
actorId: $actor?->id,
|
||||||
|
actorEmail: $actor?->email,
|
||||||
|
actorName: $actor?->name,
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onboardingCompleted(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
|
||||||
|
{
|
||||||
|
$context = $this->sanitizeContext($context);
|
||||||
|
|
||||||
|
return $this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActions::TENANT_ONBOARDING_COMPLETED,
|
||||||
|
context: $context,
|
||||||
|
actorId: $actor?->id,
|
||||||
|
actorEmail: $actor?->email,
|
||||||
|
actorName: $actor?->name,
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sanitizeContext(array $context): array
|
||||||
|
{
|
||||||
|
$keysToStrip = [
|
||||||
|
'secret',
|
||||||
|
'client_secret',
|
||||||
|
'app_client_secret',
|
||||||
|
'app_secret',
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
return Arr::except($context, $keysToStrip);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Services/TenantOnboardingSessionService.php
Normal file
142
app/Services/TenantOnboardingSessionService.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantOnboardingSessionService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Start a new onboarding session, or resume an existing active session.
|
||||||
|
*/
|
||||||
|
public function startOrResume(User $user, ?Tenant $tenant = null): TenantOnboardingSession
|
||||||
|
{
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$existing = TenantOnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof TenantOnboardingSession) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant?->getKey(),
|
||||||
|
'created_by_user_id' => $user->getKey(),
|
||||||
|
'status' => 'active',
|
||||||
|
'current_step' => 'welcome',
|
||||||
|
'payload' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeById(User $user, string $sessionId): TenantOnboardingSession
|
||||||
|
{
|
||||||
|
$session = TenantOnboardingSession::query()->whereKey($sessionId)->firstOrFail();
|
||||||
|
|
||||||
|
if ((int) $session->created_by_user_id !== (int) $user->getKey()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist wizard progress + non-secret payload.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function persistProgress(TenantOnboardingSession $session, string $currentStep, array $payload, ?Tenant $tenant = null): TenantOnboardingSession
|
||||||
|
{
|
||||||
|
$payload = $this->sanitizePayload($payload);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($session, $currentStep, $payload, $tenant): TenantOnboardingSession {
|
||||||
|
$session->forceFill([
|
||||||
|
'current_step' => $currentStep,
|
||||||
|
'payload' => array_merge($session->payload ?? [], $payload),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$session->tenant()->associate($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session->save();
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
// If another active session already exists for the tenant, resume it.
|
||||||
|
if (($tenant instanceof Tenant) && $this->isActiveSessionUniqueViolation($exception)) {
|
||||||
|
$existing = TenantOnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof TenantOnboardingSession) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function sanitizePayload(array $payload): array
|
||||||
|
{
|
||||||
|
$forbiddenKeys = [
|
||||||
|
'app_client_secret',
|
||||||
|
'client_secret',
|
||||||
|
'secret',
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->forgetKeysRecursive($payload, $forbiddenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<int, string> $forbiddenKeys
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function forgetKeysRecursive(array $payload, array $forbiddenKeys): array
|
||||||
|
{
|
||||||
|
foreach ($forbiddenKeys as $key) {
|
||||||
|
Arr::forget($payload, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
if (! is_array($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload[$key] = $this->forgetKeysRecursive($value, $forbiddenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isActiveSessionUniqueViolation(QueryException $exception): bool
|
||||||
|
{
|
||||||
|
$message = Str::lower($exception->getMessage());
|
||||||
|
|
||||||
|
return str_contains($message, 'tenant_onboarding_sessions_active_unique')
|
||||||
|
|| str_contains($message, 'unique') && str_contains($message, 'tenant_onboarding_sessions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,4 +16,9 @@ enum AuditActionId: string
|
|||||||
|
|
||||||
// Diagnostics / repair actions.
|
// Diagnostics / repair actions.
|
||||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||||
|
|
||||||
|
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
||||||
|
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
||||||
|
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
||||||
|
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/Support/Audit/AuditActions.php
Normal file
9
app/Support/Audit/AuditActions.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Audit;
|
||||||
|
|
||||||
|
final class AuditActions
|
||||||
|
{
|
||||||
|
public const TENANT_ONBOARDING_CREDENTIALS_UPDATED = 'tenant.onboarding.credentials.updated';
|
||||||
|
public const TENANT_ONBOARDING_COMPLETED = 'tenant.onboarding.completed';
|
||||||
|
}
|
||||||
@ -24,6 +24,19 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_SYNC = 'tenant.sync';
|
public const TENANT_SYNC = 'tenant.sync';
|
||||||
|
|
||||||
|
// Managed tenants (tenantless CRUD + onboarding)
|
||||||
|
public const TENANT_MANAGED_TENANTS_VIEW = 'tenant_managed_tenants.view';
|
||||||
|
|
||||||
|
public const TENANT_MANAGED_TENANTS_CREATE = 'tenant_managed_tenants.create';
|
||||||
|
|
||||||
|
public const TENANT_MANAGED_TENANTS_MANAGE = 'tenant_managed_tenants.manage';
|
||||||
|
|
||||||
|
public const TENANT_MANAGED_TENANTS_ARCHIVE = 'tenant_managed_tenants.archive';
|
||||||
|
|
||||||
|
public const TENANT_MANAGED_TENANTS_RESTORE = 'tenant_managed_tenants.restore';
|
||||||
|
|
||||||
|
public const TENANT_MANAGED_TENANTS_FORCE_DELETE = 'tenant_managed_tenants.force_delete';
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||||
|
|
||||||
@ -55,6 +68,18 @@ class Capabilities
|
|||||||
// Audit
|
// Audit
|
||||||
public const AUDIT_VIEW = 'audit.view';
|
public const AUDIT_VIEW = 'audit.view';
|
||||||
|
|
||||||
|
// Workspaces
|
||||||
|
public const WORKSPACE_VIEW = 'workspace.view';
|
||||||
|
|
||||||
|
public const WORKSPACE_MANAGE = 'workspace.manage';
|
||||||
|
|
||||||
|
public const WORKSPACE_ARCHIVE = 'workspace.archive';
|
||||||
|
|
||||||
|
// Workspace memberships
|
||||||
|
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
|
||||||
|
|
||||||
|
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all capability constants
|
* Get all capability constants
|
||||||
*
|
*
|
||||||
|
|||||||
@ -44,9 +44,7 @@ class UiEnforcement
|
|||||||
*/
|
*/
|
||||||
private ?\Closure $bulkPreflight = null;
|
private ?\Closure $bulkPreflight = null;
|
||||||
|
|
||||||
public function __construct(private string $capability)
|
public function __construct(private string $capability) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function for(string $capability): self
|
public static function for(string $capability): self
|
||||||
{
|
{
|
||||||
@ -418,6 +416,7 @@ private function resolveTenantIdsForRecords(Collection $records): array
|
|||||||
|
|
||||||
if ($resolved instanceof Tenant) {
|
if ($resolved instanceof Tenant) {
|
||||||
$ids[] = (int) $resolved->getKey();
|
$ids[] = (int) $resolved->getKey();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,4 +11,3 @@ public static function insufficientPermission(): string
|
|||||||
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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';
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
|
BadgeDomain::OnboardingTaskStatus->value => Domains\OnboardingTaskStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -26,6 +26,7 @@ enum BadgeDomain: string
|
|||||||
case IgnoredAt = 'ignored_at';
|
case IgnoredAt = 'ignored_at';
|
||||||
case RestorePreviewDecision = 'restore_preview_decision';
|
case RestorePreviewDecision = 'restore_preview_decision';
|
||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
|
case OnboardingTaskStatus = 'onboarding_task.status';
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
case ProviderConnectionStatus = 'provider_connection.status';
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
case ProviderConnectionHealth = 'provider_connection.health';
|
||||||
}
|
}
|
||||||
|
|||||||
23
app/Support/Badges/Domains/OnboardingTaskStatusBadge.php
Normal file
23
app/Support/Badges/Domains/OnboardingTaskStatusBadge.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class OnboardingTaskStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'warn' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'fail' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/Support/ManagedTenants/ManagedTenantContext.php
Normal file
102
app/Support/ManagedTenants/ManagedTenantContext.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ManagedTenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
final class ManagedTenantContext
|
||||||
|
{
|
||||||
|
public const CURRENT_TENANT_ID_SESSION_KEY = 'managed_tenants.current_id';
|
||||||
|
|
||||||
|
public const ARCHIVED_TENANT_ID_SESSION_KEY = 'managed_tenants.archived_id';
|
||||||
|
|
||||||
|
public static function setCurrentTenant(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
Session::put(self::CURRENT_TENANT_ID_SESSION_KEY, (int) $tenant->getKey());
|
||||||
|
Session::forget(self::ARCHIVED_TENANT_ID_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setArchivedTenant(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
Session::put(self::ARCHIVED_TENANT_ID_SESSION_KEY, (int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentTenantId(): ?int
|
||||||
|
{
|
||||||
|
$id = Session::get(self::CURRENT_TENANT_ID_SESSION_KEY);
|
||||||
|
|
||||||
|
if (is_int($id)) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($id) && ctype_digit($id)) {
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function archivedTenantId(): ?int
|
||||||
|
{
|
||||||
|
$id = Session::get(self::ARCHIVED_TENANT_ID_SESSION_KEY);
|
||||||
|
|
||||||
|
if (is_int($id)) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($id) && ctype_digit($id)) {
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$id = self::currentTenantId();
|
||||||
|
|
||||||
|
if (! is_int($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->find($id);
|
||||||
|
|
||||||
|
if (! $tenant?->isActive()) {
|
||||||
|
self::clearCurrentTenant();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function archivedTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$id = self::archivedTenantId();
|
||||||
|
|
||||||
|
if (! is_int($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::withTrashed()->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear(): void
|
||||||
|
{
|
||||||
|
self::clearCurrentTenant();
|
||||||
|
self::clearArchivedTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearCurrentTenant(): void
|
||||||
|
{
|
||||||
|
Session::forget(self::CURRENT_TENANT_ID_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearArchivedTenant(): void
|
||||||
|
{
|
||||||
|
Session::forget(self::ARCHIVED_TENANT_ID_SESSION_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
75
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use Closure;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureFilamentTenantSelected
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Closure(Request): Response $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (filled(Filament::getTenant())) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeTenant = $request->route()?->parameter('tenant');
|
||||||
|
|
||||||
|
if ($routeTenant instanceof Tenant) {
|
||||||
|
Filament::setTenant($routeTenant);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
$tenant = $user->tenants()
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
$tenant = $user->tenants()
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
$tenant = $user->tenants()
|
||||||
|
->withTrashed()
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Support/Onboarding/OnboardingFixHints.php
Normal file
54
app/Support/Onboarding/OnboardingFixHints.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
|
||||||
|
final class OnboardingFixHints
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function forReason(?string $reasonCode): array
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
|
||||||
|
return match ($normalized) {
|
||||||
|
RunFailureSanitizer::REASON_PERMISSION_DENIED => [
|
||||||
|
'Confirm admin consent is granted for all required Microsoft Graph permissions.',
|
||||||
|
'Verify the Azure app registration has the correct API permissions assigned.',
|
||||||
|
'Re-run “Verify permissions” after updating consent/permissions.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED => [
|
||||||
|
'Confirm the client secret is valid and not expired.',
|
||||||
|
'Verify the tenant ID and client ID are correct for this connection.',
|
||||||
|
'Re-save credentials and re-run the task.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_GRAPH_THROTTLED, RunFailureSanitizer::REASON_GRAPH_TIMEOUT => [
|
||||||
|
'Wait a few minutes and try again (transient Graph errors are common).',
|
||||||
|
'Run the task during off-peak hours if throttling persists.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_PROVIDER_OUTAGE => [
|
||||||
|
'Check Microsoft 365 service health / Graph status and try again later.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_VALIDATION_ERROR => [
|
||||||
|
'Double-check the selected provider connection and tenant settings.',
|
||||||
|
'Review the error message for which input is invalid (no secrets are shown).',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_CONFLICT_DETECTED => [
|
||||||
|
'Review what changed in the tenant before rerunning.',
|
||||||
|
'If a conflicting configuration exists, resolve it and re-run the task.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'Retry the task and review the latest evidence message.',
|
||||||
|
'If the issue persists, ask an Owner to review tenant access and connection settings.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Support/Onboarding/OnboardingTaskCatalog.php
Normal file
86
app/Support/Onboarding/OnboardingTaskCatalog.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
final class OnboardingTaskCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{task_type: string, title: string, step: int, prerequisites: array<int, string>}>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::VerifyPermissions,
|
||||||
|
'title' => 'Verify permissions',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::ConsentStatus,
|
||||||
|
'title' => 'Check consent status',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::ConnectionDiagnostics,
|
||||||
|
'title' => 'Run connection diagnostics',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::InitialSync,
|
||||||
|
'title' => 'Initial sync',
|
||||||
|
'step' => 5,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{task_type: string, title: string, step: int, prerequisites: array<int, string>}|null
|
||||||
|
*/
|
||||||
|
public static function find(string $taskType): ?array
|
||||||
|
{
|
||||||
|
foreach (self::all() as $task) {
|
||||||
|
if ($task['task_type'] === $taskType) {
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $latestEvidenceStatusByTaskType
|
||||||
|
*/
|
||||||
|
public static function prerequisitesMet(string $taskType, array $latestEvidenceStatusByTaskType): bool
|
||||||
|
{
|
||||||
|
return count(self::unmetPrerequisites($taskType, $latestEvidenceStatusByTaskType)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $latestEvidenceStatusByTaskType
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function unmetPrerequisites(string $taskType, array $latestEvidenceStatusByTaskType): array
|
||||||
|
{
|
||||||
|
$task = self::find($taskType);
|
||||||
|
|
||||||
|
if (! $task) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unmet = [];
|
||||||
|
|
||||||
|
foreach ($task['prerequisites'] as $requiredTaskType) {
|
||||||
|
$status = $latestEvidenceStatusByTaskType[$requiredTaskType] ?? 'unknown';
|
||||||
|
|
||||||
|
if ($status !== 'ok') {
|
||||||
|
$unmet[] = $requiredTaskType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unmet;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Support/Onboarding/OnboardingTaskType.php
Normal file
32
app/Support/Onboarding/OnboardingTaskType.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
final class OnboardingTaskType
|
||||||
|
{
|
||||||
|
public const string VerifyPermissions = 'onboarding.permissions.verify';
|
||||||
|
|
||||||
|
public const string ConsentStatus = 'onboarding.consent.status';
|
||||||
|
|
||||||
|
public const string ConnectionDiagnostics = 'onboarding.connection.diagnostics';
|
||||||
|
|
||||||
|
public const string InitialSync = 'onboarding.initial_sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::VerifyPermissions,
|
||||||
|
self::ConsentStatus,
|
||||||
|
self::ConnectionDiagnostics,
|
||||||
|
self::InitialSync,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isKnown(string $taskType): bool
|
||||||
|
{
|
||||||
|
return in_array($taskType, self::all(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ public static function labels(): array
|
|||||||
'restore_run.restore' => 'Restore restore runs',
|
'restore_run.restore' => 'Restore restore runs',
|
||||||
'restore_run.force_delete' => 'Force delete restore runs',
|
'restore_run.force_delete' => 'Force delete restore runs',
|
||||||
'tenant.sync' => 'Tenant sync',
|
'tenant.sync' => 'Tenant sync',
|
||||||
|
'tenant.rbac.verify' => 'Tenant RBAC verification',
|
||||||
'policy_version.prune' => 'Prune policy versions',
|
'policy_version.prune' => 'Prune policy versions',
|
||||||
'policy_version.restore' => 'Restore policy versions',
|
'policy_version.restore' => 'Restore policy versions',
|
||||||
'policy_version.force_delete' => 'Delete policy versions',
|
'policy_version.force_delete' => 'Delete policy versions',
|
||||||
@ -57,6 +58,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
|||||||
return match (trim($operationType)) {
|
return match (trim($operationType)) {
|
||||||
'policy.sync', 'policy.sync_one' => 90,
|
'policy.sync', 'policy.sync_one' => 90,
|
||||||
'provider.connection.check' => 30,
|
'provider.connection.check' => 30,
|
||||||
|
'tenant.rbac.verify' => 60,
|
||||||
'policy.export' => 120,
|
'policy.export' => 120,
|
||||||
'inventory.sync' => 180,
|
'inventory.sync' => 180,
|
||||||
'compliance.snapshot' => 180,
|
'compliance.snapshot' => 180,
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use Closure;
|
use Closure;
|
||||||
@ -349,10 +352,42 @@ private function applyServerSideGuard(): void
|
|||||||
/**
|
/**
|
||||||
* Resolve the current access context with an optional record.
|
* Resolve the current access context with an optional record.
|
||||||
*/
|
*/
|
||||||
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext|WorkspaceAccessContext
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$workspace = $this->resolveWorkspaceWithRecord($record);
|
||||||
|
|
||||||
|
if ($workspace instanceof Workspace) {
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return new WorkspaceAccessContext(
|
||||||
|
user: null,
|
||||||
|
workspace: null,
|
||||||
|
isMember: false,
|
||||||
|
hasCapability: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$hasCapability = true;
|
||||||
|
if ($this->capability !== null && $isMember) {
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
$hasCapability = $resolver->can($user, $workspace, $this->capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkspaceAccessContext(
|
||||||
|
user: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
isMember: $isMember,
|
||||||
|
hasCapability: $hasCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
||||||
$tenant = $this->resolveTenantWithRecord($record);
|
$tenant = $this->resolveTenantWithRecord($record);
|
||||||
|
|
||||||
@ -383,6 +418,33 @@ private function resolveContextWithRecord(?Model $record = null): TenantAccessCo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
|
||||||
|
{
|
||||||
|
if ($record instanceof Workspace) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof WorkspaceMembership) {
|
||||||
|
return $record->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->record !== null) {
|
||||||
|
$resolved = $this->record instanceof Closure
|
||||||
|
? ($this->record)()
|
||||||
|
: $this->record;
|
||||||
|
|
||||||
|
if ($resolved instanceof Workspace) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolved instanceof WorkspaceMembership) {
|
||||||
|
return $resolved->workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the tenant for this action with an optional record.
|
* Resolve the tenant for this action with an optional record.
|
||||||
*
|
*
|
||||||
|
|||||||
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([
|
$middleware->alias([
|
||||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
||||||
|
'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class,
|
||||||
|
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->prependToPriorityList(
|
$middleware->prependToPriorityList(
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'onboarding' => [
|
||||||
|
'credentials_required' => (bool) env('TENANT_ONBOARDING_CREDENTIALS_REQUIRED', false),
|
||||||
|
],
|
||||||
|
|
||||||
'supported_policy_types' => [
|
'supported_policy_types' => [
|
||||||
[
|
[
|
||||||
'type' => 'deviceConfiguration',
|
'type' => 'deviceConfiguration',
|
||||||
|
|||||||
32
database/factories/OnboardingEvidenceFactory.php
Normal file
32
database/factories/OnboardingEvidenceFactory.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OnboardingEvidence>
|
||||||
|
*/
|
||||||
|
class OnboardingEvidenceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OnboardingEvidence::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'onboarding_session_id' => null,
|
||||||
|
'provider_connection_id' => null,
|
||||||
|
'task_type' => 'onboarding.unknown',
|
||||||
|
'status' => 'unknown',
|
||||||
|
'reason_code' => null,
|
||||||
|
'message' => null,
|
||||||
|
'payload' => [],
|
||||||
|
'operation_run_id' => null,
|
||||||
|
'recorded_at' => now(),
|
||||||
|
'recorded_by_user_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
database/factories/OnboardingSessionFactory.php
Normal file
30
database/factories/OnboardingSessionFactory.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OnboardingSession>
|
||||||
|
*/
|
||||||
|
class OnboardingSessionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OnboardingSession::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'provider_connection_id' => null,
|
||||||
|
'status' => 'draft',
|
||||||
|
'current_step' => 1,
|
||||||
|
'assigned_to_user_id' => null,
|
||||||
|
'locked_by_user_id' => null,
|
||||||
|
'locked_until' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
'metadata' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,6 +18,7 @@ class TenantFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'workspace_id' => Workspace::factory(),
|
||||||
'name' => fake()->company(),
|
'name' => fake()->company(),
|
||||||
'external_id' => fake()->uuid(),
|
'external_id' => fake()->uuid(),
|
||||||
'tenant_id' => fake()->uuid(),
|
'tenant_id' => fake()->uuid(),
|
||||||
|
|||||||
36
database/factories/TenantOnboardingSessionFactory.php
Normal file
36
database/factories/TenantOnboardingSessionFactory.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TenantOnboardingSession>
|
||||||
|
*/
|
||||||
|
class TenantOnboardingSessionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TenantOnboardingSession::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'created_by_user_id' => User::factory(),
|
||||||
|
'status' => 'active',
|
||||||
|
'current_step' => 'welcome',
|
||||||
|
'payload' => [],
|
||||||
|
'last_error_code' => null,
|
||||||
|
'last_error_message' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
'abandoned_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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')
|
->where('tenant_id', 'local-tenant')
|
||||||
->update(['status' => 'archived', 'is_current' => false]);
|
->update(['status' => 'archived', 'is_current' => false]);
|
||||||
|
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL');
|
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,44 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('tenant_onboarding_sessions', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->enum('status', ['active', 'completed', 'abandoned'])->default('active');
|
||||||
|
$table->enum('current_step', ['welcome', 'tenant_details', 'credentials', 'permissions', 'verification'])->default('welcome');
|
||||||
|
$table->jsonb('payload')->nullable();
|
||||||
|
$table->string('last_error_code')->nullable();
|
||||||
|
$table->text('last_error_message')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamp('abandoned_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['created_by_user_id', 'status']);
|
||||||
|
$table->index(['tenant_id', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement("CREATE UNIQUE INDEX tenant_onboarding_sessions_active_unique ON tenant_onboarding_sessions (tenant_id) WHERE status = 'active' AND tenant_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS tenant_onboarding_sessions_active_unique');
|
||||||
|
|
||||||
|
Schema::dropIfExists('tenant_onboarding_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('onboarding_status')->default('not_started')->after('status');
|
||||||
|
$table->timestamp('onboarding_completed_at')->nullable()->after('onboarding_status');
|
||||||
|
$table->index('onboarding_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['onboarding_status']);
|
||||||
|
$table->dropColumn('onboarding_completed_at');
|
||||||
|
$table->dropColumn('onboarding_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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,47 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('onboarding_sessions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('status')->default('draft');
|
||||||
|
$table->unsignedSmallInteger('current_step')->default(1);
|
||||||
|
|
||||||
|
$table->foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('locked_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('locked_until')->nullable();
|
||||||
|
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->jsonb('metadata')->default('{}');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'status', 'created_at']);
|
||||||
|
$table->index(['tenant_id', 'provider_connection_id', 'created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// At most one active session per tenant.
|
||||||
|
DB::statement("CREATE UNIQUE INDEX onboarding_sessions_active_unique ON onboarding_sessions (tenant_id) WHERE status IN ('draft', 'in_progress')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS onboarding_sessions_active_unique');
|
||||||
|
Schema::dropIfExists('onboarding_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?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('onboarding_evidence', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('onboarding_session_id')->nullable()->constrained('onboarding_sessions')->nullOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('task_type');
|
||||||
|
$table->string('status')->default('unknown');
|
||||||
|
$table->string('reason_code')->nullable();
|
||||||
|
$table->string('message')->nullable();
|
||||||
|
$table->jsonb('payload')->default('{}');
|
||||||
|
|
||||||
|
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||||
|
$table->timestamp('recorded_at');
|
||||||
|
$table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'task_type', 'recorded_at']);
|
||||||
|
$table->index(['tenant_id', 'task_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('onboarding_evidence');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user