Compare commits
11 Commits
feat/999-m
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ff671d8d4a | |||
| d56ba85755 | |||
| fb1046c97a | |||
| 05a604cfb6 | |||
| 53dc89e6ef | |||
| 8e34b6084f | |||
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 |
@ -1,14 +1,18 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
|
*.log*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
|||||||
15
.github/agents/copilot-instructions.md
vendored
15
.github/agents/copilot-instructions.md
vendored
@ -14,10 +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)
|
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||||
- PostgreSQL (via Sail) (068-workspaces-v2)
|
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (069-managed-tenant-onboarding-wizard)
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
|
||||||
- PostgreSQL (Sail) (069-managed-tenant-onboarding-wizard)
|
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -37,10 +37,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 068-workspaces-v2: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4
|
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||||
- 069-managed-tenant-onboarding-wizard: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
*.cache
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
@ -24,6 +25,7 @@ coverage/
|
|||||||
/storage/pail
|
/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|||||||
@ -50,8 +50,7 @@ ### 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 strictly separated authorization planes:
|
- The platform MUST maintain two 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.
|
||||||
@ -70,11 +69,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
|
||||||
- Workspace membership and tenant membership (and plane membership) are isolation boundaries.
|
- Tenant membership (and plane membership) is an isolation boundary.
|
||||||
- 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
|
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
||||||
respond as 404 (deny-as-not-found) for scope-scoped routes/actions/resources.
|
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||||
- This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`),
|
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||||
Global Search results, and all action endpoints (Livewire calls included).
|
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.
|
||||||
@ -175,4 +174,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.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-31
|
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
||||||
|
|||||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return Tenant::currentOrFail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return collect([Tenant::current()]);
|
return collect([Tenant::currentOrFail()]);
|
||||||
} catch (RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@ -69,6 +70,8 @@ public function selectTenant(int $tenantId): void
|
|||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,11 +8,13 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ChooseWorkspace extends Page
|
class ChooseWorkspace extends Page
|
||||||
{
|
{
|
||||||
@ -37,6 +39,12 @@ protected function getHeaderActions(): array
|
|||||||
Action::make('createWorkspace')
|
Action::make('createWorkspace')
|
||||||
->label('Create workspace')
|
->label('Create workspace')
|
||||||
->modalHeading('Create workspace')
|
->modalHeading('Create workspace')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User
|
||||||
|
&& Gate::forUser($user)->check('create', Workspace::class);
|
||||||
|
})
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
->required()
|
->required()
|
||||||
@ -100,7 +108,9 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
|
|
||||||
$context->setCurrentWorkspace($workspace, $user, request());
|
$context->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
$this->redirect('/admin/tenants');
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
|
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,6 +124,8 @@ public function createWorkspace(array $data): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Gate::forUser($user)->authorize('create', Workspace::class);
|
||||||
|
|
||||||
$workspace = Workspace::query()->create([
|
$workspace = Workspace::query()->create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'slug' => $data['slug'] ?? null,
|
'slug' => $data['slug'] ?? null,
|
||||||
@ -132,6 +144,43 @@ public function createWorkspace(array $data): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect('/admin/tenants');
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
|
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectAfterWorkspaceSelected(User $user): string
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return self::getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return self::getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantsQuery = $user->tenants()
|
||||||
|
->where('workspace_id', $workspace->getKey())
|
||||||
|
->where('status', 'active');
|
||||||
|
|
||||||
|
$tenantCount = (int) $tenantsQuery->count();
|
||||||
|
|
||||||
|
if ($tenantCount === 0) {
|
||||||
|
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantCount === 1) {
|
||||||
|
$tenant = $tenantsQuery->first();
|
||||||
|
|
||||||
|
if ($tenant !== null) {
|
||||||
|
return TenantDashboard::getUrl(tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChooseTenant::getUrl();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
<?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()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
<?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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Alerts extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Alerts';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'alerts';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Alerts';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.alerts';
|
||||||
|
}
|
||||||
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AuditLog extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Audit Log';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'audit-log';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Audit Log';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||||
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -26,6 +25,8 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public string $activeTab = 'all';
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
@ -37,89 +38,62 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
// Must be non-static
|
// Must be non-static
|
||||||
protected string $view = 'filament.pages.monitoring.operations';
|
protected string $view = 'filament.pages.monitoring.operations';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
OperationsKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedActiveTab(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return OperationRunResource::table($table)
|
||||||
->query(
|
->query(function (): Builder {
|
||||||
OperationRun::query()
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
->where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->latest('created_at')
|
|
||||||
)
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('type')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
|
|
||||||
TextColumn::make('status')
|
$query = OperationRun::query()
|
||||||
->badge()
|
->with('user')
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->latest('id')
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
->when(
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
$workspaceId,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
! $workspaceId,
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
);
|
||||||
|
|
||||||
TextColumn::make('outcome')
|
return $this->applyActiveTab($query);
|
||||||
->badge()
|
});
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
}
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
|
|
||||||
TextColumn::make('initiator_name')
|
private function applyActiveTab(Builder $query): Builder
|
||||||
->label('Initiator')
|
{
|
||||||
->searchable(),
|
return match ($this->activeTab) {
|
||||||
|
'active' => $query->whereIn('status', [
|
||||||
TextColumn::make('created_at')
|
OperationRunStatus::Queued->value,
|
||||||
->dateTime()
|
OperationRunStatus::Running->value,
|
||||||
->sortable()
|
]),
|
||||||
->label('Started'),
|
'succeeded' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
TextColumn::make('duration')
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
->getStateUsing(function (OperationRun $record) {
|
'partial' => $query
|
||||||
if ($record->started_at && $record->completed_at) {
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
||||||
}
|
'failed' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
return '-';
|
->where('outcome', OperationRunOutcome::Failed->value),
|
||||||
}),
|
default => $query,
|
||||||
])
|
};
|
||||||
->filters([
|
|
||||||
SelectFilter::make('outcome')
|
|
||||||
->options([
|
|
||||||
'succeeded' => 'Succeeded',
|
|
||||||
'partially_succeeded' => 'Partially Succeeded',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'cancelled' => 'Cancelled',
|
|
||||||
'pending' => 'Pending',
|
|
||||||
]),
|
|
||||||
|
|
||||||
SelectFilter::make('type')
|
|
||||||
->options(
|
|
||||||
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->distinct()
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->toArray()
|
|
||||||
),
|
|
||||||
|
|
||||||
Filter::make('created_at')
|
|
||||||
->form([
|
|
||||||
DatePicker::make('created_from'),
|
|
||||||
DatePicker::make('created_until'),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
return $query
|
|
||||||
->when(
|
|
||||||
$data['created_from'],
|
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$data['created_until'],
|
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
// View action handled by opening a modal or side-peek
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,6 +80,6 @@ public function createWorkspace(array $data): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect('/admin/tenants');
|
$this->redirect(ChooseTenant::getUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,358 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,863 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
126
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
126
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Operations;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantlessOperationRunViewer extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Operation run';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
||||||
|
|
||||||
|
public OperationRun $run;
|
||||||
|
|
||||||
|
public bool $opsUxIsTabHidden = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action|ActionGroup>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$actions = [
|
||||||
|
Action::make('refresh')
|
||||||
|
->label('Refresh')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => isset($this->run)
|
||||||
|
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||||
|
: route('admin.operations.index')),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$related = OperationRunLinks::related($this->run, $tenant);
|
||||||
|
|
||||||
|
$relatedActions = [];
|
||||||
|
|
||||||
|
foreach ($related as $label => $url) {
|
||||||
|
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
||||||
|
->label((string) $label)
|
||||||
|
->url((string) $url)
|
||||||
|
->openUrlInNewTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relatedActions !== []) {
|
||||||
|
$actions[] = ActionGroup::make($relatedActions)
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(OperationRun $run): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||||
|
|
||||||
|
if ($workspaceId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isMember) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return OperationRunResource::infolist($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultInfolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->record($this->run)
|
||||||
|
->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
EmbeddedSchema::make('infolist'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -21,12 +23,42 @@ public static function getLabel(): string
|
|||||||
|
|
||||||
public static function canView(): bool
|
public static function canView(): bool
|
||||||
{
|
{
|
||||||
return false;
|
$user = auth()->user();
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
if (! $user instanceof User) {
|
||||||
{
|
return false;
|
||||||
abort(404);
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$canRegisterInWorkspace = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', ['owner', 'manager'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($canRegisterInWorkspace) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
@ -79,6 +111,12 @@ protected function handleRegistration(array $data): Model
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -1,820 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
184
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class TenantRequiredPermissions extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'required-permissions';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Required permissions';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||||
|
|
||||||
|
public string $status = 'missing';
|
||||||
|
|
||||||
|
public string $type = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public array $features = [];
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $viewModel = [];
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$queryFeatures = request()->query('features', $this->features);
|
||||||
|
|
||||||
|
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
|
'status' => request()->query('status', $this->status),
|
||||||
|
'type' => request()->query('type', $this->type),
|
||||||
|
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||||
|
'search' => request()->query('search', $this->search),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->status = $state['status'];
|
||||||
|
$this->type = $state['type'];
|
||||||
|
$this->features = $state['features'];
|
||||||
|
$this->search = $state['search'];
|
||||||
|
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatus(): void
|
||||||
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedType(): void
|
||||||
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedFeatures(): void
|
||||||
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyFeatureFilter(string $feature): void
|
||||||
|
{
|
||||||
|
$feature = trim($feature);
|
||||||
|
|
||||||
|
if ($feature === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($feature, $this->features, true)) {
|
||||||
|
$this->features = array_values(array_filter(
|
||||||
|
$this->features,
|
||||||
|
static fn (string $value): bool => $value !== $feature,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$this->features[] = $feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->features = array_values(array_unique($this->features));
|
||||||
|
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFeatureFilter(): void
|
||||||
|
{
|
||||||
|
$this->features = [];
|
||||||
|
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->status = 'missing';
|
||||||
|
$this->type = 'all';
|
||||||
|
$this->features = [];
|
||||||
|
$this->search = '';
|
||||||
|
|
||||||
|
$this->refreshViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshViewModel(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->viewModel = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||||
|
|
||||||
|
$this->viewModel = $builder->build($tenant, [
|
||||||
|
'status' => $this->status,
|
||||||
|
'type' => $this->type,
|
||||||
|
'features' => $this->features,
|
||||||
|
'search' => $this->search,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$filters = $this->viewModel['filters'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($filters)) {
|
||||||
|
$this->status = (string) ($filters['status'] ?? $this->status);
|
||||||
|
$this->type = (string) ($filters['type'] ?? $this->type);
|
||||||
|
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
||||||
|
$this->search = (string) ($filters['search'] ?? $this->search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reRunVerificationUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionId = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if (! is_int($connectionId)) {
|
||||||
|
return ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
2164
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
2164
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class ManagedTenantsLanding extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Managed tenants';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
||||||
|
|
||||||
|
public Workspace $workspace;
|
||||||
|
|
||||||
|
public function mount(Workspace $workspace): void
|
||||||
|
{
|
||||||
|
$this->workspace = $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function getTenants(): Collection
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->tenants()
|
||||||
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToChooseTenant(): void
|
||||||
|
{
|
||||||
|
$this->redirect(ChooseTenant::getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openTenant(int $tenantId): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
|
->whereKey($tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -938,7 +938,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
|
|||||||
|
|
||||||
public static function assignTenant(array $data): array
|
public static function assignTenant(array $data): array
|
||||||
{
|
{
|
||||||
$data['tenant_id'] = Tenant::current()->getKey();
|
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||||
->defaultSort('scheduled_for', 'desc')
|
->defaultSort('scheduled_for', 'desc')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('scheduled_for')
|
Tables\Columns\TextColumn::make('scheduled_for')
|
||||||
|
|||||||
@ -2,18 +2,24 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VerificationCheckAcknowledgement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -33,6 +39,8 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $slug = 'operations';
|
protected static ?string $slug = 'operations';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
@ -41,12 +49,13 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with('user')
|
->with('user')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
||||||
|
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -81,6 +90,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('target_scope_empty_state')
|
||||||
|
->label('Target')
|
||||||
|
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
||||||
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
||||||
|
->columnSpanFull(),
|
||||||
TextEntry::make('elapsed')
|
TextEntry::make('elapsed')
|
||||||
->label('Elapsed')
|
->label('Elapsed')
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
||||||
@ -136,12 +150,92 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Verification report')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('verification_report')
|
||||||
|
->label('')
|
||||||
|
->view('filament.components.verification-report-viewer')
|
||||||
|
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||||
|
->viewData(function (OperationRun $record): array {
|
||||||
|
$report = VerificationReportViewer::report($record);
|
||||||
|
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||||
|
|
||||||
|
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
||||||
|
|
||||||
|
$previousRunUrl = null;
|
||||||
|
|
||||||
|
if ($changeIndicator !== null) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
$previousRunUrl = $tenant instanceof Tenant
|
||||||
|
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||||
|
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||||
|
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||||
|
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||||
|
->where('operation_run_id', (int) $record->getKey())
|
||||||
|
->with('acknowledgedByUser')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||||
|
$user = $ack->acknowledgedByUser;
|
||||||
|
|
||||||
|
return [
|
||||||
|
(string) $ack->check_key => [
|
||||||
|
'check_key' => (string) $ack->check_key,
|
||||||
|
'ack_reason' => (string) $ack->ack_reason,
|
||||||
|
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||||
|
'expires_at' => $ack->expires_at?->toJSON(),
|
||||||
|
'acknowledged_by' => $user instanceof User
|
||||||
|
? [
|
||||||
|
'id' => (int) $user->getKey(),
|
||||||
|
'name' => (string) $user->name,
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'run' => [
|
||||||
|
'id' => (int) $record->getKey(),
|
||||||
|
'type' => (string) $record->type,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'started_at' => $record->started_at?->toJSON(),
|
||||||
|
'completed_at' => $record->completed_at?->toJSON(),
|
||||||
|
],
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
'changeIndicator' => $changeIndicator,
|
||||||
|
'previousRunUrl' => $previousRunUrl,
|
||||||
|
'acknowledgements' => $acknowledgements,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Context')
|
Section::make('Context')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('context')
|
ViewEntry::make('context')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
->state(function (OperationRun $record): array {
|
||||||
|
$context = $record->context ?? [];
|
||||||
|
$context = is_array($context) ? $context : [];
|
||||||
|
|
||||||
|
if (array_key_exists('verification_report', $context)) {
|
||||||
|
$context['verification_report'] = [
|
||||||
|
'redacted' => true,
|
||||||
|
'note' => 'Rendered in the Verification report section.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@ -187,16 +281,47 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(function (): array {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->default(function (): ?string {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $tenant->getKey();
|
||||||
|
})
|
||||||
|
->searchable(),
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('type')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if (! $tenantId) {
|
if ($workspaceId === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
->select('type')
|
->select('type')
|
||||||
->distinct()
|
->distinct()
|
||||||
->orderBy('type')
|
->orderBy('type')
|
||||||
@ -214,14 +339,20 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
Tables\Filters\SelectFilter::make('initiator_name')
|
||||||
->label('Initiator')
|
->label('Initiator')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if (! $tenantId) {
|
if ($workspaceId === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
||||||
|
? (int) $tenant->getKey()
|
||||||
|
: null;
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
||||||
->whereNotNull('initiator_name')
|
->whereNotNull('initiator_name')
|
||||||
->select('initiator_name')
|
->select('initiator_name')
|
||||||
->distinct()
|
->distinct()
|
||||||
@ -257,17 +388,16 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make()
|
||||||
|
->label('View run')
|
||||||
|
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
'index' => Pages\ListOperationRuns::route('/'),
|
|
||||||
'view' => Pages\ViewOperationRun::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ListOperationRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Tab>
|
|
||||||
*/
|
|
||||||
public function getTabs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'all' => Tab::make(),
|
|
||||||
'active' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])),
|
|
||||||
'succeeded' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
|
||||||
'partial' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
|
||||||
'failed' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTablePollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ViewOperationRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRun $run */
|
|
||||||
$run = $this->getRecord();
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($run, $tenant);
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
|
||||||
->label($label)
|
|
||||||
->url($url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($actions)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make($actions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -894,7 +894,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -815,7 +815,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -15,11 +14,13 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
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\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -99,9 +100,16 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
if ($workspaceId === null) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
})
|
})
|
||||||
->defaultSort('display_name')
|
->defaultSort('display_name')
|
||||||
->columns([
|
->columns([
|
||||||
@ -175,29 +183,22 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'provider.connection.check',
|
initiator: $user,
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
@ -640,9 +641,17 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
->latest('id');
|
->latest('id');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
|||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||||
|
|||||||
@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
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\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -15,6 +13,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -117,18 +116,6 @@ 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')
|
||||||
@ -180,7 +167,7 @@ protected function getHeaderActions(): array
|
|||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -198,18 +185,9 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'provider.connection.check',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
@ -219,7 +219,7 @@ public static function getWizardSteps(): array
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -10,10 +9,9 @@
|
|||||||
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\Models\WorkspaceMembership;
|
||||||
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;
|
||||||
@ -57,8 +55,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -66,9 +62,7 @@ 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 = 'Managed tenants';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Managed tenants';
|
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
@ -78,7 +72,21 @@ public static function canCreate(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::userCanManageTenantsInCurrentWorkspace($user);
|
if (static::userCanManageAnyTenant($user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', ['owner', 'manager'])
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
@ -89,12 +97,11 @@ public static function canEdit(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof Tenant
|
return $record instanceof Tenant
|
||||||
&& $workspace instanceof Workspace
|
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
|
||||||
&& static::userCanManageTenantsInCurrentWorkspace($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
@ -105,12 +112,11 @@ public static function canDelete(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof Tenant
|
return $record instanceof Tenant
|
||||||
&& $workspace instanceof Workspace
|
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
&& (int) $record->workspace_id === (int) $workspace->getKey()
|
|
||||||
&& static::userCanDeleteTenantsInCurrentWorkspace($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
@ -121,49 +127,21 @@ public static function canDeleteAny(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::userCanDeleteTenantsInCurrentWorkspace($user);
|
return static::userCanDeleteAnyTenant($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanDeleteTenantsInCurrentWorkspace(User $user): bool
|
private static function userCanManageAnyTenant(User $user): bool
|
||||||
{
|
{
|
||||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
return $user->tenantMemberships()
|
||||||
|
->pluck('role')
|
||||||
if (! $workspace instanceof Workspace) {
|
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanManageTenantsInCurrentWorkspace(User $user): bool
|
private static function userCanDeleteAnyTenant(User $user): bool
|
||||||
{
|
{
|
||||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
return $user->tenantMemberships()
|
||||||
|
->pluck('role')
|
||||||
if (! $workspace instanceof Workspace) {
|
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||||
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
|
||||||
@ -210,21 +188,27 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspace = static::resolveCurrentWorkspaceFor($user);
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
if ($workspaceId === null) {
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantIds = $user->tenants()
|
||||||
|
->withTrashed()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->pluck('tenants.id');
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->whereIn('id', $tenantIds)
|
||||||
->withCount('policies')
|
->withCount('policies')
|
||||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||||
}
|
}
|
||||||
@ -299,11 +283,6 @@ 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')
|
||||||
@ -434,7 +413,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_MANAGED_TENANTS_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
@ -454,7 +433,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_MANAGED_TENANTS_RESTORE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,7 +450,7 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
@ -541,7 +520,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_MANAGED_TENANTS_ARCHIVE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,7 +543,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
@ -587,7 +566,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_MANAGED_TENANTS_FORCE_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -620,7 +599,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
@ -917,12 +896,8 @@ 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 => (bool) $record?->isActive())
|
->visible(fn (Tenant $record): 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,10 +2,8 @@
|
|||||||
|
|
||||||
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 App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
@ -14,38 +12,17 @@ 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
|
* @param array<string, mixed> $data
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
unset(
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
$data['app_client_id'],
|
|
||||||
$data['app_client_secret'],
|
|
||||||
$data['app_certificate_thumbprint'],
|
|
||||||
$data['app_notes'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
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;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,9 +38,4 @@ 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_MANAGED_TENANTS_ARCHIVE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->tooltip('You do not have permission to archive managed tenants.')
|
->tooltip('You do not have permission to archive tenants.')
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -11,26 +10,12 @@ 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\Action::make('add_managed_tenant')
|
Actions\CreateAction::make()
|
||||||
->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 add managed tenants.'),
|
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?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,8 +2,8 @@
|
|||||||
|
|
||||||
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\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -24,6 +24,7 @@ protected function getHeaderWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantArchivedBanner::class,
|
TenantArchivedBanner::class,
|
||||||
|
RecentOperationsSummary::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,25 +32,6 @@ 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')
|
||||||
|
|||||||
@ -25,11 +25,22 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
->label(__('Email'))
|
->label(__('Domain'))
|
||||||
|
->getStateUsing(function (TenantMembership $record): ?string {
|
||||||
|
$email = $record->user?->email;
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) str($email)->after('@')->lower();
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label(__('Name'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
Tables\Columns\TextColumn::make('role')
|
||||||
->badge()
|
->badge()
|
||||||
@ -49,7 +60,13 @@ public function table(Table $table): Table
|
|||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
->options(fn () => User::query()
|
||||||
|
->orderBy('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||||
|
])
|
||||||
|
->all()),
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label(__('Role'))
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
|
|||||||
@ -3,9 +3,33 @@
|
|||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateWorkspace extends CreateRecord
|
class CreateWorkspace extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = WorkspaceResource::class;
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->firstOrCreate(
|
||||||
|
[
|
||||||
|
'workspace_id' => $this->record->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'role' => 'owner',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,19 +3,9 @@
|
|||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\ViewAction;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditWorkspace extends EditRecord
|
class EditWorkspace extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = WorkspaceResource::class;
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
ViewAction::make(),
|
|
||||||
DeleteAction::make(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListWorkspaces extends ListRecords
|
class ListWorkspaces extends ListRecords
|
||||||
@ -13,7 +13,7 @@ class ListWorkspaces extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,27 +3,17 @@
|
|||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
|
|
||||||
class ViewWorkspace extends ViewRecord
|
class ViewWorkspace extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = WorkspaceResource::class;
|
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
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,209 +0,0 @@
|
|||||||
<?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([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
<?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\Auth\WorkspaceRole;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
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 WorkspaceMembershipsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
|
->label(__('User'))
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
|
->label(__('Domain'))
|
||||||
|
->getStateUsing(function (WorkspaceMembership $record): ?string {
|
||||||
|
$email = $record->user?->email;
|
||||||
|
|
||||||
|
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) str($email)->after('@')->lower();
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('role')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
WorkspaceUiEnforcement::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('email')
|
||||||
|
->get(['id', 'name', 'email'])
|
||||||
|
->mapWithKeys(fn (User $user): array => [
|
||||||
|
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||||
|
])
|
||||||
|
->all()),
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
WorkspaceRole::Owner->value => __('Owner'),
|
||||||
|
WorkspaceRole::Manager->value => __('Manager'),
|
||||||
|
WorkspaceRole::Operator->value => __('Operator'),
|
||||||
|
WorkspaceRole::Readonly->value => __('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();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('change_role')
|
||||||
|
->label(__('Change role'))
|
||||||
|
->icon('heroicon-o-pencil')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
WorkspaceRole::Owner->value => __('Owner'),
|
||||||
|
WorkspaceRole::Manager->value => __('Manager'),
|
||||||
|
WorkspaceRole::Operator->value => __('Operator'),
|
||||||
|
WorkspaceRole::Readonly->value => __('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();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
|
->label(__('Remove'))
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->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();
|
||||||
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?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('-'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,78 +2,101 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Workspaces;
|
namespace App\Filament\Resources\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\Pages\CreateWorkspace;
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||||
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 App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Support\Icons\Heroicon;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
class WorkspaceResource extends Resource
|
class WorkspaceResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Workspace::class;
|
protected static ?string $model = Workspace::class;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'name';
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $breadcrumb = 'Manage workspaces';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->whereIn('id', function ($subQuery) use ($user): void {
|
||||||
|
$subQuery->from('workspace_memberships')
|
||||||
|
->select('workspace_id')
|
||||||
|
->where('user_id', $user->getKey());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return WorkspaceForm::configure($schema);
|
return $schema
|
||||||
}
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
public static function infolist(Schema $schema): Schema
|
->required()
|
||||||
{
|
->maxLength(255),
|
||||||
return WorkspaceInfolist::configure($schema);
|
Forms\Components\TextInput::make('slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return WorkspacesTable::configure($table);
|
return $table
|
||||||
}
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
public static function getEloquentQuery(): Builder
|
->searchable()
|
||||||
{
|
->sortable(),
|
||||||
$user = auth()->user();
|
Tables\Columns\TextColumn::make('slug')
|
||||||
|
->searchable()
|
||||||
if (! $user instanceof User) {
|
->sortable(),
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
])
|
||||||
}
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
$workspaceIds = $user->newQuery()
|
Actions\EditAction::make(),
|
||||||
->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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => ListWorkspaces::route('/'),
|
'index' => Pages\ListWorkspaces::route('/'),
|
||||||
'create' => CreateWorkspace::route('/create'),
|
'create' => Pages\CreateWorkspace::route('/create'),
|
||||||
'view' => ViewWorkspace::route('/{record}'),
|
'view' => Pages\ViewWorkspace::route('/{record}'),
|
||||||
'edit' => EditWorkspace::route('/{record}/edit'),
|
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceMembershipsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
|
||||||
|
final class VerificationReportChangeIndicator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{state: 'no_changes'|'changed', previous_report_id: int}|null
|
||||||
|
*/
|
||||||
|
public static function forRun(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
$report = VerificationReportViewer::report($run);
|
||||||
|
|
||||||
|
if ($report === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousRun = VerificationReportViewer::previousRun($run, $report);
|
||||||
|
|
||||||
|
if ($previousRun === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousReport = VerificationReportViewer::report($previousRun);
|
||||||
|
|
||||||
|
if ($previousReport === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentFingerprint = VerificationReportViewer::fingerprint($report);
|
||||||
|
$previousFingerprint = VerificationReportViewer::fingerprint($previousReport);
|
||||||
|
|
||||||
|
if ($currentFingerprint === null || $previousFingerprint === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => $currentFingerprint === $previousFingerprint ? 'no_changes' : 'changed',
|
||||||
|
'previous_report_id' => (int) $previousRun->getKey(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
92
app/Filament/Support/VerificationReportViewer.php
Normal file
92
app/Filament/Support/VerificationReportViewer.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Verification\VerificationReportFingerprint;
|
||||||
|
use App\Support\Verification\VerificationReportSanitizer;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
|
|
||||||
|
final class VerificationReportViewer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function report(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||||
|
|
||||||
|
if (! VerificationReportSchema::isValidReport($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function previousReportId(array $report): ?int
|
||||||
|
{
|
||||||
|
$previousReportId = $report['previous_report_id'] ?? null;
|
||||||
|
|
||||||
|
if (is_int($previousReportId) && $previousReportId > 0) {
|
||||||
|
return $previousReportId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
|
||||||
|
return (int) trim($previousReportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fingerprint(array $report): ?string
|
||||||
|
{
|
||||||
|
$fingerprint = $report['fingerprint'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($fingerprint)) {
|
||||||
|
$fingerprint = strtolower(trim($fingerprint));
|
||||||
|
|
||||||
|
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||||
|
return $fingerprint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationReportFingerprint::forReport($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function previousRun(OperationRun $run, array $report): ?OperationRun
|
||||||
|
{
|
||||||
|
$previousReportId = self::previousReportId($report);
|
||||||
|
|
||||||
|
if ($previousReportId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = OperationRun::query()
|
||||||
|
->whereKey($previousReportId)
|
||||||
|
->where('tenant_id', (int) $run->tenant_id)
|
||||||
|
->where('workspace_id', (int) $run->workspace_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $previous instanceof OperationRun ? $previous : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shouldRenderForRun(OperationRun $run): bool
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
if (array_key_exists('verification_report', $context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class RepairWorkspaceOwners extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
|
||||||
|
|
||||||
|
protected string $view = 'filament.system.pages.repair-workspace-owners';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$breakGlass = app(BreakGlassSession::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('assign_owner')
|
||||||
|
->label('Assign owner (break-glass)')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Assign workspace owner')
|
||||||
|
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
|
||||||
|
->form([
|
||||||
|
Select::make('workspace_id')
|
||||||
|
->label('Workspace')
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return Workspace::query()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Select::make('target_user_id')
|
||||||
|
->label('User')
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return User::query()
|
||||||
|
->where('email', 'like', "%{$search}%")
|
||||||
|
->orderBy('email')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('email', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()->whereKey((int) $value)->value('email');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
$platformUser = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $platformUser instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $breakGlass->isActive()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
||||||
|
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
||||||
|
$reason = (string) ($data['reason'] ?? '');
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
||||||
|
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
||||||
|
|
||||||
|
$membership = WorkspaceMembership::query()->firstOrNew([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $targetUser->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fromRole = $membership->exists ? (string) $membership->role : null;
|
||||||
|
|
||||||
|
$membership->forceFill([
|
||||||
|
'role' => WorkspaceRole::Owner->value,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'actor_user_id' => (int) $platformUser->getKey(),
|
||||||
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
|
'attempted_role' => WorkspaceRole::Owner->value,
|
||||||
|
'from_role' => $fromRole,
|
||||||
|
'reason' => trim($reason),
|
||||||
|
'source' => 'break_glass',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: null,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
actorId: (int) $platformUser->getKey(),
|
||||||
|
actorEmail: $platformUser->email,
|
||||||
|
actorName: $platformUser->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Owner assigned')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->disabled(fn (): bool => ! $breakGlass->isActive()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -81,10 +80,10 @@ protected function getStats(): array
|
|||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||||
Stat::make('Active operations', $activeRuns)
|
Stat::make('Active operations', $activeRuns)
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
->url(route('admin.operations.index')),
|
||||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
Stat::make('Inventory active', $inventoryActiveRuns)
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
->url(route('admin.operations.index')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,12 +40,7 @@ protected function getStats(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [];
|
||||||
Stat::make('Total Runs (30 days)', 0),
|
|
||||||
Stat::make('Active Runs', 0),
|
|
||||||
Stat::make('Failed/Partial (7 days)', 0),
|
|
||||||
Stat::make('Avg Duration (7 days)', '—'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|||||||
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class RecentOperationsSummary extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'tenant' => null,
|
||||||
|
'runs' => collect(),
|
||||||
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, OperationRun> $runs */
|
||||||
|
$runs = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(5)
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'outcome',
|
||||||
|
'created_at',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'runs' => $runs,
|
||||||
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ClearTenantContextController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||||
|
|
||||||
|
return redirect()->to('/admin/operations');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Http/Controllers/SelectTenantController.php
Normal file
74
app/Http/Controllers/SelectTenantController.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class SelectTenantController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return redirect()->to('/admin/choose-workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'tenant_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereKey($validated['tenant_id'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||||
|
|
||||||
|
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
|
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasTable('user_tenant_preferences')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserTenantPreference::query()->updateOrCreate(
|
||||||
|
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
||||||
|
['last_used_at' => now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
74
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class SwitchWorkspaceController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'workspace_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($validated['workspace_id'])->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);
|
||||||
|
|
||||||
|
$intendedUrl = WorkspaceIntendedUrl::consume($request);
|
||||||
|
|
||||||
|
if ($intendedUrl !== null) {
|
||||||
|
return redirect()->to($intendedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantsQuery = $user->tenants()
|
||||||
|
->where('workspace_id', $workspace->getKey())
|
||||||
|
->where('status', 'active');
|
||||||
|
|
||||||
|
$tenantCount = (int) $tenantsQuery->count();
|
||||||
|
|
||||||
|
if ($tenantCount === 0) {
|
||||||
|
return redirect()->route('admin.onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantCount === 1) {
|
||||||
|
$tenant = $tenantsQuery->first();
|
||||||
|
|
||||||
|
if ($tenant !== null) {
|
||||||
|
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(ChooseTenant::getUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,11 +3,14 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response as HttpResponse;
|
use Illuminate\Http\Response as HttpResponse;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
@ -28,11 +31,11 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$path = '/'.ltrim($request->path(), '/');
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
|
||||||
if (str_starts_with($path, '/admin/t/')) {
|
if ($this->isWorkspaceOptionalPath($request, $path)) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
if (str_starts_with($path, '/admin/t/')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,8 +63,38 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
->exists()
|
->exists()
|
||||||
: $membershipQuery->exists();
|
: $membershipQuery->exists();
|
||||||
|
|
||||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||||
|
|
||||||
|
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
||||||
|
? '/admin/choose-workspace'
|
||||||
|
: '/admin/no-access';
|
||||||
|
|
||||||
|
if ($target === '/admin/choose-workspace') {
|
||||||
|
WorkspaceIntendedUrl::storeFromRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
return new HttpResponse('', 302, ['Location' => $target]);
|
return new HttpResponse('', 302, ['Location' => $target]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||||
|
{
|
||||||
|
if (str_starts_with($path, '/admin/workspaces')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/livewire/update') {
|
||||||
|
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||||
|
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||||
|
|
||||||
|
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,11 +7,17 @@
|
|||||||
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\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Intune\TenantPermissionService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -83,17 +89,146 @@ public function handle(
|
|||||||
|
|
||||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||||
|
|
||||||
|
$permissionService = app(TenantPermissionService::class);
|
||||||
|
|
||||||
|
$graphOptions = null;
|
||||||
|
|
||||||
|
if ($result->healthy) {
|
||||||
|
try {
|
||||||
|
$graphOptions = app(ProviderGateway::class)->graphOptions($connection);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$graphOptions = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionComparison = $result->healthy
|
||||||
|
? ($graphOptions === null
|
||||||
|
? $permissionService->compare(
|
||||||
|
$tenant,
|
||||||
|
persist: false,
|
||||||
|
liveCheck: false,
|
||||||
|
useConfiguredStub: false,
|
||||||
|
)
|
||||||
|
: $permissionService->compare(
|
||||||
|
$tenant,
|
||||||
|
persist: true,
|
||||||
|
liveCheck: true,
|
||||||
|
useConfiguredStub: false,
|
||||||
|
graphOptions: $graphOptions,
|
||||||
|
))
|
||||||
|
: $permissionService->compare(
|
||||||
|
$tenant,
|
||||||
|
persist: false,
|
||||||
|
liveCheck: false,
|
||||||
|
useConfiguredStub: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$permissionRows = $permissionComparison['permissions'] ?? [];
|
||||||
|
$permissionRows = is_array($permissionRows) ? $permissionRows : [];
|
||||||
|
|
||||||
|
$inventory = null;
|
||||||
|
|
||||||
|
if (! $result->healthy) {
|
||||||
|
$inventory = [
|
||||||
|
'fresh' => false,
|
||||||
|
'reason_code' => $result->reasonCode ?? 'dependency_unreachable',
|
||||||
|
'message' => 'Provider connection check failed; permissions were not refreshed during this run.',
|
||||||
|
];
|
||||||
|
} elseif ($graphOptions === null) {
|
||||||
|
$inventory = [
|
||||||
|
'fresh' => false,
|
||||||
|
'reason_code' => 'provider_credential_missing',
|
||||||
|
'message' => 'Provider credentials were unavailable; observed permissions inventory was not refreshed during this run.',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$liveCheck = $permissionComparison['live_check'] ?? null;
|
||||||
|
$liveCheck = is_array($liveCheck) ? $liveCheck : [];
|
||||||
|
|
||||||
|
$reasonCode = is_string($liveCheck['reason_code'] ?? null) ? (string) $liveCheck['reason_code'] : 'dependency_unreachable';
|
||||||
|
$appId = is_string($liveCheck['app_id'] ?? null) && $liveCheck['app_id'] !== '' ? (string) $liveCheck['app_id'] : null;
|
||||||
|
$observedCount = is_numeric($liveCheck['observed_permissions_count'] ?? null)
|
||||||
|
? (int) $liveCheck['observed_permissions_count']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$message = ($liveCheck['succeeded'] ?? false) === true
|
||||||
|
? 'Observed permissions inventory refreshed successfully.'
|
||||||
|
: match ($reasonCode) {
|
||||||
|
'permissions_inventory_empty' => $appId !== null
|
||||||
|
? sprintf('No application permissions were detected for app id %s. Verify admin consent was granted for this exact app registration, then retry verification.', $appId)
|
||||||
|
: 'No application permissions were detected. Verify admin consent was granted for the configured app registration, then retry verification.',
|
||||||
|
default => 'Unable to refresh observed permissions inventory during this run. Retry verification.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$inventory = [
|
||||||
|
'fresh' => ($liveCheck['succeeded'] ?? false) === true,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
'app_id' => $appId,
|
||||||
|
'observed_permissions_count' => $observedCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
|
||||||
|
|
||||||
|
$report = VerificationReportWriter::write(
|
||||||
|
run: $this->operationRun,
|
||||||
|
checks: [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => $result->healthy ? 'pass' : 'fail',
|
||||||
|
'severity' => $result->healthy ? 'info' : 'critical',
|
||||||
|
'blocking' => ! $result->healthy,
|
||||||
|
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||||
|
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||||
|
'evidence' => array_values(array_filter([
|
||||||
|
[
|
||||||
|
'kind' => 'provider_connection_id',
|
||||||
|
'value' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'kind' => 'entra_tenant_id',
|
||||||
|
'value' => (string) $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||||
|
'kind' => 'http_status',
|
||||||
|
'value' => (int) $result->meta['http_status'],
|
||||||
|
] : null,
|
||||||
|
is_string($result->meta['organization_id'] ?? null) ? [
|
||||||
|
'kind' => 'organization_id',
|
||||||
|
'value' => (string) $result->meta['organization_id'],
|
||||||
|
] : null,
|
||||||
|
])),
|
||||||
|
'next_steps' => $result->healthy
|
||||||
|
? []
|
||||||
|
: [[
|
||||||
|
'label' => 'Review provider connection',
|
||||||
|
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||||
|
'record' => (int) $connection->getKey(),
|
||||||
|
], tenant: $tenant),
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
...$permissionChecks,
|
||||||
|
],
|
||||||
|
identity: [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->healthy) {
|
if ($result->healthy) {
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
@ -103,6 +238,8 @@ public function handle(
|
|||||||
'message' => $result->message ?? 'Health check failed.',
|
'message' => $result->message ?? 'Health check failed.',
|
||||||
]],
|
]],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||||
@ -145,4 +282,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
|||||||
'last_error_message' => $result->healthy ? null : $result->message,
|
'last_error_message' => $result->healthy ? null : $result->message,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
|
||||||
|
{
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $report['summary']['counts'] ?? [];
|
||||||
|
$counts = is_array($counts) ? $counts : [];
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::VerificationCompleted->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'counts' => $counts,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
<?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.',
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||||
$existingPolicyIds = $backupSet
|
$existingPolicyIds = $backupSet
|
||||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Monitoring;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class OperationsDetail extends Component implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
// Ensure tenant scope
|
|
||||||
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
|
|
||||||
$this->run = $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.monitoring.operations-detail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,9 +21,4 @@ public function tenant(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,11 +21,41 @@ class OperationRun extends Model
|
|||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $operationRun): void {
|
||||||
|
if ($operationRun->workspace_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operationRun->tenant_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
@ -26,6 +26,11 @@ public function tenant(): BelongsTo
|
|||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function credential(): HasOne
|
public function credential(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
||||||
|
|||||||
@ -21,13 +21,20 @@ class Tenant extends Model implements HasName
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_ONBOARDING = 'onboarding';
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_ARCHIVED = 'archived';
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'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',
|
||||||
@ -70,7 +77,16 @@ protected static function booted(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (empty($tenant->status)) {
|
if (empty($tenant->status)) {
|
||||||
$tenant->status = 'active';
|
$tenant->status = self::STATUS_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace_id === null && app()->runningUnitTests()) {
|
||||||
|
$workspace = Workspace::query()->create([
|
||||||
|
'name' => 'Test Workspace',
|
||||||
|
'slug' => 'test-'.Str::lower(Str::random(10)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->workspace_id = (int) $workspace->getKey();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -85,12 +101,12 @@ protected static function booted(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant->status = 'archived';
|
$tenant->status = self::STATUS_ARCHIVED;
|
||||||
$tenant->saveQuietly();
|
$tenant->saveQuietly();
|
||||||
});
|
});
|
||||||
|
|
||||||
static::restored(function (Tenant $tenant) {
|
static::restored(function (Tenant $tenant) {
|
||||||
$tenant->forceFill(['status' => 'active'])->saveQuietly();
|
$tenant->forceFill(['status' => self::STATUS_ACTIVE])->saveQuietly();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,12 +114,12 @@ public static function activeQuery(): Builder
|
|||||||
{
|
{
|
||||||
return static::query()
|
return static::query()
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where('status', 'active');
|
->where('status', self::STATUS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function makeCurrent(): void
|
public function makeCurrent(): void
|
||||||
{
|
{
|
||||||
if ($this->trashed() || $this->status !== 'active') {
|
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
|
||||||
throw new RuntimeException('Only active tenants can be made current.');
|
throw new RuntimeException('Only active tenants can be made current.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +134,7 @@ public function makeCurrent(): void
|
|||||||
$this->forceFill(['is_current' => true]);
|
$this->forceFill(['is_current' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(): self
|
public static function current(): ?self
|
||||||
{
|
{
|
||||||
$filamentTenant = Filament::getTenant();
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
@ -147,6 +163,13 @@ public static function current(): self
|
|||||||
->where('is_current', true)
|
->where('is_current', true)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentOrFail(): self
|
||||||
|
{
|
||||||
|
$tenant = static::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw new RuntimeException('No current tenant selected.');
|
throw new RuntimeException('No current tenant selected.');
|
||||||
}
|
}
|
||||||
@ -177,11 +200,6 @@ public function workspace(): BelongsTo
|
|||||||
return $this->belongsTo(Workspace::class);
|
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);
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
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\BelongsTo;
|
||||||
@ -12,29 +11,82 @@ class TenantOnboardingSession extends Model
|
|||||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use HasUuids;
|
protected $table = 'managed_tenant_onboarding_sessions';
|
||||||
|
|
||||||
public $incrementing = false;
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
protected $keyType = 'string';
|
*/
|
||||||
|
public const STATE_ALLOWED_KEYS = [
|
||||||
|
'entra_tenant_id',
|
||||||
|
'tenant_id',
|
||||||
|
'tenant_name',
|
||||||
|
'environment',
|
||||||
|
'primary_domain',
|
||||||
|
'notes',
|
||||||
|
'provider_connection_id',
|
||||||
|
'selected_provider_connection_id',
|
||||||
|
'verification_operation_run_id',
|
||||||
|
'verification_run_id',
|
||||||
|
'bootstrap_operation_types',
|
||||||
|
'bootstrap_operation_runs',
|
||||||
|
'bootstrap_run_ids',
|
||||||
|
'connection_recently_updated',
|
||||||
|
];
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'payload' => 'array',
|
'state' => 'array',
|
||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
'abandoned_at' => 'datetime',
|
|
||||||
'created_at' => 'datetime',
|
|
||||||
'updated_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $value
|
||||||
|
*/
|
||||||
|
public function setStateAttribute(?array $value): void
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
$this->attributes['state'] = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = array_intersect_key($value, array_flip(self::STATE_ALLOWED_KEYS));
|
||||||
|
|
||||||
|
$encoded = json_encode($allowed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$this->attributes['state'] = $encoded !== false ? $encoded : json_encode([], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createdByUser(): BelongsTo
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function startedByUser(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
return $this->belongsTo(User::class, 'started_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function updatedByUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Models\Contracts\HasDefaultTenant;
|
use Filament\Models\Contracts\HasDefaultTenant;
|
||||||
use Filament\Models\Contracts\HasTenants;
|
use Filament\Models\Contracts\HasTenants;
|
||||||
@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection
|
|||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
$tenantId = null;
|
$tenantId = null;
|
||||||
|
|
||||||
if ($this->tenantPreferencesTableExists()) {
|
if ($this->tenantPreferencesTableExists()) {
|
||||||
@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
|
|
||||||
if ($tenantId !== null) {
|
if ($tenantId !== null) {
|
||||||
$tenant = $this->tenants()
|
$tenant = $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VerificationCheckAcknowledgement extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\VerificationCheckAcknowledgementFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'acknowledged_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acknowledgedByUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -33,8 +33,20 @@ public function toDatabase(object $notifiable): array
|
|||||||
{
|
{
|
||||||
$tenant = $this->run->tenant;
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
|
$wizard = $context['wizard'] ?? null;
|
||||||
|
|
||||||
|
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||||
|
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||||
|
|
||||||
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||||
|
|
||||||
|
$runUrl = match (true) {
|
||||||
|
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||||
|
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
return FilamentNotification::make()
|
return FilamentNotification::make()
|
||||||
->title("{$operationLabel} queued")
|
->title("{$operationLabel} queued")
|
||||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||||
@ -42,7 +54,7 @@ public function toDatabase(object $notifiable): array
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->getDatabaseMessage();
|
->getDatabaseMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,8 +3,9 @@
|
|||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Auth\Access\Response;
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
@ -14,31 +15,31 @@ class OperationRunPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if (! $tenant) {
|
if ($workspaceId === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
return WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, OperationRun $run): Response|bool
|
public function view(User $user, OperationRun $run): Response|bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||||
|
|
||||||
if (! $tenant) {
|
if ($workspaceId <= 0) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
$isMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return $isMember ? true : Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
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\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Auth\Access\Response;
|
use Illuminate\Auth\Access\Response;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@ -15,15 +17,31 @@ class ProviderConnectionPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return Gate::forUser($user)->allows('provider.view', $tenant);
|
return $tenant instanceof Tenant
|
||||||
|
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||||
|
&& Gate::forUser($user)->allows('provider.view', $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, ProviderConnection $connection): Response|bool
|
public function view(User $user, ProviderConnection $connection): Response|bool
|
||||||
{
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -32,20 +50,40 @@ public function view(User $user, ProviderConnection $connection): Response|bool
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return Gate::forUser($user)->allows('provider.manage', $tenant);
|
return $tenant instanceof Tenant
|
||||||
|
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||||
|
&& Gate::forUser($user)->allows('provider.manage', $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, ProviderConnection $connection): Response|bool
|
public function update(User $user, ProviderConnection $connection): Response|bool
|
||||||
{
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -54,13 +92,26 @@ public function update(User $user, ProviderConnection $connection): Response|boo
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(User $user, ProviderConnection $connection): Response|bool
|
public function delete(User $user, ProviderConnection $connection): Response|bool
|
||||||
{
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
|
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -69,6 +120,19 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentWorkspace(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return is_int($workspaceId)
|
||||||
|
? Workspace::query()->whereKey($workspaceId)->first()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,45 +7,54 @@
|
|||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
class WorkspacePolicy
|
class WorkspacePolicy
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can view any models.
|
* Determine whether the user can view any models.
|
||||||
*/
|
*/
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool|Response
|
||||||
{
|
{
|
||||||
return true;
|
return Response::allow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can view the model.
|
* Determine whether the user can view the model.
|
||||||
*/
|
*/
|
||||||
public function view(User $user, Workspace $workspace): bool
|
public function view(User $user, Workspace $workspace): bool|Response
|
||||||
{
|
{
|
||||||
return WorkspaceMembership::query()
|
$isMember = WorkspaceMembership::query()
|
||||||
->where('user_id', $user->getKey())
|
->where('user_id', $user->getKey())
|
||||||
->where('workspace_id', $workspace->getKey())
|
->where('workspace_id', $workspace->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
|
return $isMember ? Response::allow() : Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can create models.
|
* Determine whether the user can create models.
|
||||||
*/
|
*/
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool|Response
|
||||||
{
|
{
|
||||||
return true;
|
return Response::allow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can update the model.
|
* Determine whether the user can update the model.
|
||||||
*/
|
*/
|
||||||
public function update(User $user, Workspace $workspace): bool
|
public function update(User $user, Workspace $workspace): bool|Response
|
||||||
{
|
{
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)
|
||||||
|
? Response::allow()
|
||||||
|
: Response::deny();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -33,9 +33,6 @@
|
|||||||
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;
|
||||||
@ -87,28 +84,6 @@ 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,19 +2,12 @@
|
|||||||
|
|
||||||
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\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\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -26,10 +19,6 @@ 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
|
||||||
@ -39,20 +28,28 @@ public function boot(): void
|
|||||||
$tenantResolver = app(CapabilityResolver::class);
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
|
||||||
Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool {
|
|
||||||
return $workspaceResolver->can($user, $workspace, $capability);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($tenantResolver, $capability): bool {
|
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return $tenantResolver->can($user, $tenant, $capability);
|
return $tenantResolver->can($user, $tenant, $capability);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
||||||
|
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $workspaceResolver->can($user, $workspace, $capability);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
foreach (Capabilities::all() as $capability) {
|
foreach (Capabilities::all() as $capability) {
|
||||||
if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) {
|
if (str_starts_with($capability, 'workspace')) {
|
||||||
$defineWorkspaceCapability($capability);
|
$defineWorkspaceCapability($capability);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -5,29 +5,21 @@
|
|||||||
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\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\TenantOnboardingWizard;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
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;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
@ -37,7 +29,6 @@
|
|||||||
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;
|
||||||
@ -52,64 +43,11 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseTenant::registerRoutes($panel);
|
|
||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
|
|
||||||
TenantOnboardingWizard::registerRoutes($panel);
|
WorkspaceResource::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')
|
||||||
@ -118,10 +56,52 @@ public function panel(Panel $panel): Panel
|
|||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
|
->navigationItems([
|
||||||
|
NavigationItem::make('Manage workspaces')
|
||||||
|
->url(function (): string {
|
||||||
|
return route('filament.admin.resources.workspaces.index');
|
||||||
|
})
|
||||||
|
->icon('heroicon-o-squares-2x2')
|
||||||
|
->group('Settings')
|
||||||
|
->sort(10)
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->whereIn('role', $roles)
|
||||||
|
->exists();
|
||||||
|
}),
|
||||||
|
NavigationItem::make('Operations')
|
||||||
|
->url(fn (): string => route('admin.operations.index'))
|
||||||
|
->icon('heroicon-o-queue-list')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(10),
|
||||||
|
NavigationItem::make('Alerts')
|
||||||
|
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||||
|
->icon('heroicon-o-bell-alert')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(20),
|
||||||
|
NavigationItem::make('Audit Log')
|
||||||
|
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||||
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(30),
|
||||||
|
])
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::HEAD_END,
|
PanelsRenderHook::HEAD_END,
|
||||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||||
)
|
)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::TOPBAR_START,
|
||||||
|
fn () => view('filament.partials.context-bar')->render()
|
||||||
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_END,
|
PanelsRenderHook::BODY_END,
|
||||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||||
@ -140,24 +120,6 @@ 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,
|
||||||
@ -167,14 +129,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,
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
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()) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Audit\AuditContextSanitizer;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
class WorkspaceAuditLogger
|
||||||
@ -19,21 +20,28 @@ public function log(
|
|||||||
string $status = 'success',
|
string $status = 'success',
|
||||||
?string $resourceType = null,
|
?string $resourceType = null,
|
||||||
?string $resourceId = null,
|
?string $resourceId = null,
|
||||||
|
?int $actorId = null,
|
||||||
|
?string $actorEmail = null,
|
||||||
|
?string $actorName = null,
|
||||||
): AuditLog {
|
): AuditLog {
|
||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
|
|
||||||
|
$metadata = is_array($metadata) ? $metadata : [];
|
||||||
|
|
||||||
|
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||||
|
|
||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => null,
|
'tenant_id' => null,
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'actor_id' => $actor?->getKey(),
|
'actor_id' => $actor?->getKey() ?? $actorId,
|
||||||
'actor_email' => $actor?->email,
|
'actor_email' => $actor?->email ?? $actorEmail,
|
||||||
'actor_name' => $actor?->name,
|
'actor_name' => $actor?->name ?? $actorName,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'metadata' => $metadata + $context,
|
'metadata' => $sanitizedMetadata,
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
'recorded_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,39 +4,27 @@
|
|||||||
|
|
||||||
namespace App\Services\Auth;
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Collection;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class PostLoginRedirectResolver
|
class PostLoginRedirectResolver
|
||||||
{
|
{
|
||||||
public function resolve(User $user): string
|
public function resolve(User $user): string
|
||||||
{
|
{
|
||||||
$tenants = $this->getActiveTenants($user);
|
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||||
|
|
||||||
if ($tenants->isEmpty()) {
|
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||||
|
? $membershipQuery
|
||||||
|
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||||
|
->whereNull('workspaces.archived_at')
|
||||||
|
->exists()
|
||||||
|
: $membershipQuery->exists();
|
||||||
|
|
||||||
|
if (! $hasAnyActiveMembership) {
|
||||||
return '/admin/no-access';
|
return '/admin/no-access';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenants->count() === 1) {
|
return '/admin';
|
||||||
/** @var Tenant $tenant */
|
|
||||||
$tenant = $tenants->first();
|
|
||||||
|
|
||||||
return TenantDashboard::getUrl(tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/admin/choose-tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function getActiveTenants(User $user): Collection
|
|
||||||
{
|
|
||||||
return $user->tenants()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,15 +19,9 @@ 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,
|
||||||
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||||
@ -49,14 +43,9 @@ 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,
|
||||||
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
|
|
||||||
@ -75,8 +64,6 @@ 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,
|
||||||
|
|
||||||
@ -94,8 +81,6 @@ 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,
|
||||||
|
|
||||||
|
|||||||
@ -16,9 +16,7 @@
|
|||||||
|
|
||||||
class WorkspaceMembershipManager
|
class WorkspaceMembershipManager
|
||||||
{
|
{
|
||||||
public function __construct(public WorkspaceAuditLogger $auditLogger)
|
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addMember(
|
public function addMember(
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
@ -30,65 +28,82 @@ public function addMember(
|
|||||||
$this->assertValidRole($role);
|
$this->assertValidRole($role);
|
||||||
$this->assertActorCanManage($actor, $workspace);
|
$this->assertActorCanManage($actor, $workspace);
|
||||||
|
|
||||||
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
try {
|
||||||
$existing = WorkspaceMembership::query()
|
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
$existing = WorkspaceMembership::query()
|
||||||
->where('user_id', (int) $member->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->first();
|
->where('user_id', (int) $member->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
if ($existing->role !== $role) {
|
if ($existing->role !== $role) {
|
||||||
$fromRole = (string) $existing->role;
|
$fromRole = (string) $existing->role;
|
||||||
|
|
||||||
$existing->forceFill([
|
$this->guardLastOwnerDemotion($workspace, $existing, $role);
|
||||||
'role' => $role,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$existing->forceFill([
|
||||||
workspace: $workspace,
|
'role' => $role,
|
||||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
])->save();
|
||||||
context: [
|
|
||||||
'metadata' => [
|
$this->auditLogger->log(
|
||||||
'member_user_id' => (int) $member->getKey(),
|
workspace: $workspace,
|
||||||
'from_role' => $fromRole,
|
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||||
'to_role' => $role,
|
context: [
|
||||||
'source' => $source,
|
'metadata' => [
|
||||||
|
'member_user_id' => (int) $member->getKey(),
|
||||||
|
'from_role' => $fromRole,
|
||||||
|
'to_role' => $role,
|
||||||
|
'source' => $source,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
actor: $actor,
|
||||||
actor: $actor,
|
status: 'success',
|
||||||
status: 'success',
|
resourceType: 'workspace',
|
||||||
resourceType: 'workspace',
|
resourceId: (string) $workspace->getKey(),
|
||||||
resourceId: (string) $workspace->getKey(),
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return $existing->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
});
|
||||||
|
} catch (DomainException $exception) {
|
||||||
|
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||||
|
$this->auditLastOwnerBlocked(
|
||||||
|
workspace: $workspace,
|
||||||
|
actor: $actor,
|
||||||
|
targetUserId: (int) $member->getKey(),
|
||||||
|
attemptedRole: $role,
|
||||||
|
currentRole: WorkspaceRole::Owner->value,
|
||||||
|
attemptedAction: 'role_change',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$membership = WorkspaceMembership::query()->create([
|
throw $exception;
|
||||||
'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
|
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
|
||||||
@ -136,20 +151,13 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi
|
|||||||
});
|
});
|
||||||
} catch (DomainException $exception) {
|
} catch (DomainException $exception) {
|
||||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||||
$this->auditLogger->log(
|
$this->auditLastOwnerBlocked(
|
||||||
workspace: $workspace,
|
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,
|
actor: $actor,
|
||||||
status: 'blocked',
|
targetUserId: (int) $membership->user_id,
|
||||||
resourceType: 'workspace',
|
attemptedRole: $newRole,
|
||||||
resourceId: (string) $workspace->getKey(),
|
currentRole: (string) $membership->role,
|
||||||
|
attemptedAction: 'role_change',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,20 +201,13 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
|
|||||||
});
|
});
|
||||||
} catch (DomainException $exception) {
|
} catch (DomainException $exception) {
|
||||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||||
$this->auditLogger->log(
|
$this->auditLastOwnerBlocked(
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'member_user_id' => (int) $membership->user_id,
|
|
||||||
'role' => (string) $membership->role,
|
|
||||||
'attempted_action' => 'remove',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
status: 'blocked',
|
targetUserId: (int) $membership->user_id,
|
||||||
resourceType: 'workspace',
|
attemptedRole: (string) $membership->role,
|
||||||
resourceId: (string) $workspace->getKey(),
|
currentRole: (string) $membership->role,
|
||||||
|
attemptedAction: 'remove',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,4 +272,32 @@ private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership
|
|||||||
throw new DomainException('You cannot remove the last remaining owner.');
|
throw new DomainException('You cannot remove the last remaining owner.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function auditLastOwnerBlocked(
|
||||||
|
Workspace $workspace,
|
||||||
|
User $actor,
|
||||||
|
int $targetUserId,
|
||||||
|
string $attemptedRole,
|
||||||
|
string $currentRole,
|
||||||
|
string $attemptedAction,
|
||||||
|
): void {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'actor_user_id' => (int) $actor->getKey(),
|
||||||
|
'target_user_id' => $targetUserId,
|
||||||
|
'attempted_role' => $attemptedRole,
|
||||||
|
'current_role' => $currentRole,
|
||||||
|
'attempted_action' => $attemptedAction,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,17 +23,39 @@ class WorkspaceRoleCapabilityMap
|
|||||||
Capabilities::WORKSPACE_ARCHIVE,
|
Capabilities::WORKSPACE_ARCHIVE,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Manager->value => [
|
WorkspaceRole::Manager->value => [
|
||||||
Capabilities::WORKSPACE_VIEW,
|
Capabilities::WORKSPACE_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Operator->value => [
|
WorkspaceRole::Operator->value => [
|
||||||
Capabilities::WORKSPACE_VIEW,
|
Capabilities::WORKSPACE_VIEW,
|
||||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Readonly->value => [
|
WorkspaceRole::Readonly->value => [
|
||||||
|
|||||||
@ -6,6 +6,25 @@
|
|||||||
|
|
||||||
class GraphContractRegistry
|
class GraphContractRegistry
|
||||||
{
|
{
|
||||||
|
public function probePath(string $key, array $replacements = []): ?string
|
||||||
|
{
|
||||||
|
$path = config("graph_contracts.probes.$key.path");
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($replacements as $placeholder => $value) {
|
||||||
|
if (! is_string($placeholder) || $placeholder === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str_replace($placeholder, urlencode((string) $value), $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/'.ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
public function directoryGroupsPolicyType(): string
|
public function directoryGroupsPolicyType(): string
|
||||||
{
|
{
|
||||||
return 'directoryGroups';
|
return 'directoryGroups';
|
||||||
|
|||||||
@ -409,7 +409,20 @@ private function shouldApplySelectFallback(GraphResponse $graphResponse, array $
|
|||||||
public function getOrganization(array $options = []): GraphResponse
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
{
|
{
|
||||||
$context = $this->resolveContext($options);
|
$context = $this->resolveContext($options);
|
||||||
$endpoint = 'organization';
|
$endpoint = $this->contracts->probePath('organization');
|
||||||
|
|
||||||
|
if (! is_string($endpoint) || $endpoint === '') {
|
||||||
|
return new GraphResponse(
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
status: 500,
|
||||||
|
errors: [[
|
||||||
|
'message' => 'Graph contract missing for probe: organization',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = ltrim($endpoint, '/');
|
||||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||||
$fullPath = $this->buildFullPath($endpoint);
|
$fullPath = $this->buildFullPath($endpoint);
|
||||||
|
|
||||||
@ -479,14 +492,27 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||||
|
|
||||||
// First, get the service principal object by clientId (appId)
|
// First, get the service principal object by clientId (appId)
|
||||||
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
|
$endpoint = $this->contracts->probePath('service_principal_by_app_id', ['{appId}' => $clientId]);
|
||||||
|
|
||||||
|
if (! is_string($endpoint) || $endpoint === '') {
|
||||||
|
return new GraphResponse(
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
status: 500,
|
||||||
|
errors: [[
|
||||||
|
'message' => 'Graph contract missing for probe: service_principal_by_app_id',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = ltrim($endpoint, '/');
|
||||||
|
|
||||||
$this->logger->logRequest('get_service_principal', [
|
$this->logger->logRequest('get_service_principal', [
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
'client_id' => $clientId,
|
'client_id' => $clientId,
|
||||||
'tenant' => $context['tenant'],
|
'tenant' => $context['tenant'],
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'full_path' => $endpoint,
|
'full_path' => $this->buildFullPath($endpoint),
|
||||||
'client_request_id' => $clientRequestId,
|
'client_request_id' => $clientRequestId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -528,14 +554,30 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now get the app role assignments (application permissions)
|
// Now get the app role assignments (application permissions)
|
||||||
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
|
$assignmentsEndpoint = $this->contracts->probePath(
|
||||||
|
'service_principal_app_role_assignments',
|
||||||
|
['{servicePrincipalId}' => $servicePrincipalId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! is_string($assignmentsEndpoint) || $assignmentsEndpoint === '') {
|
||||||
|
return new GraphResponse(
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
status: 500,
|
||||||
|
errors: [[
|
||||||
|
'message' => 'Graph contract missing for probe: service_principal_app_role_assignments',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignmentsEndpoint = ltrim($assignmentsEndpoint, '/');
|
||||||
|
|
||||||
$this->logger->logRequest('get_app_role_assignments', [
|
$this->logger->logRequest('get_app_role_assignments', [
|
||||||
'endpoint' => $assignmentsEndpoint,
|
'endpoint' => $assignmentsEndpoint,
|
||||||
'service_principal_id' => $servicePrincipalId,
|
'service_principal_id' => $servicePrincipalId,
|
||||||
'tenant' => $context['tenant'],
|
'tenant' => $context['tenant'],
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'full_path' => $assignmentsEndpoint,
|
'full_path' => $this->buildFullPath($assignmentsEndpoint),
|
||||||
'client_request_id' => $clientRequestId,
|
'client_request_id' => $clientRequestId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -545,29 +587,68 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
action: 'get_service_principal_permissions',
|
action: 'get_service_principal_permissions',
|
||||||
response: $assignmentsResponse,
|
response: $assignmentsResponse,
|
||||||
transform: function (array $json) use ($context) {
|
transform: function (array $json) use ($context) {
|
||||||
$assignments = $json['value'] ?? [];
|
$assignments = is_array($json['value'] ?? null) ? $json['value'] : [];
|
||||||
|
$assignmentsTotal = count($assignments);
|
||||||
$permissions = [];
|
$permissions = [];
|
||||||
|
|
||||||
// Get Microsoft Graph service principal to map role IDs to permission names
|
// Get Microsoft Graph service principal to map role IDs to permission names
|
||||||
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
|
$graphSpEndpoint = $this->contracts->probePath(
|
||||||
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
|
'service_principal_by_app_id',
|
||||||
$graphSps = $graphSpResponse->json('value', []);
|
['{appId}' => '00000003-0000-0000-c000-000000000000'],
|
||||||
$appRoles = $graphSps[0]['appRoles'] ?? [];
|
);
|
||||||
|
|
||||||
|
$graphSpResponse = null;
|
||||||
|
|
||||||
|
if (is_string($graphSpEndpoint) && $graphSpEndpoint !== '') {
|
||||||
|
$graphSpResponse = $this->send('GET', ltrim($graphSpEndpoint, '/'), [], $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$graphSps = $graphSpResponse instanceof Response
|
||||||
|
? $graphSpResponse->json('value', [])
|
||||||
|
: [];
|
||||||
|
$appRoles = is_array($graphSps[0]['appRoles'] ?? null) ? $graphSps[0]['appRoles'] : [];
|
||||||
|
|
||||||
// Map role IDs to permission names
|
// Map role IDs to permission names
|
||||||
$roleMap = [];
|
$roleMap = [];
|
||||||
foreach ($appRoles as $role) {
|
foreach ($appRoles as $role) {
|
||||||
$roleMap[$role['id']] = $role['value'];
|
$roleId = $role['id'] ?? null;
|
||||||
|
$value = $role['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($roleId) || $roleId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roleMap[strtolower($roleId)] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($assignments as $assignment) {
|
foreach ($assignments as $assignment) {
|
||||||
$roleId = $assignment['appRoleId'] ?? null;
|
$roleId = $assignment['appRoleId'] ?? null;
|
||||||
if ($roleId && isset($roleMap[$roleId])) {
|
|
||||||
$permissions[] = $roleMap[$roleId];
|
if (! is_string($roleId) || $roleId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRoleId = strtolower($roleId);
|
||||||
|
|
||||||
|
if (isset($roleMap[$normalizedRoleId])) {
|
||||||
|
$permissions[] = $roleMap[$normalizedRoleId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['permissions' => $permissions];
|
$permissions = array_values(array_unique($permissions));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'permissions' => $permissions,
|
||||||
|
'diagnostics' => [
|
||||||
|
'assignments_total' => $assignmentsTotal,
|
||||||
|
'mapped_total' => count($permissions),
|
||||||
|
'graph_roles_total' => count($roleMap),
|
||||||
|
],
|
||||||
|
];
|
||||||
},
|
},
|
||||||
meta: [
|
meta: [
|
||||||
'tenant' => $context['tenant'] ?? null,
|
'tenant' => $context['tenant'] ?? null,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Audit\AuditContextSanitizer;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class AuditLogger
|
class AuditLogger
|
||||||
@ -22,6 +23,10 @@ public function log(
|
|||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
|
|
||||||
|
$metadata = is_array($metadata) ? $metadata : [];
|
||||||
|
|
||||||
|
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||||
|
|
||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'actor_id' => $actorId,
|
'actor_id' => $actorId,
|
||||||
@ -31,7 +36,7 @@ public function log(
|
|||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'metadata' => $metadata + $context,
|
'metadata' => $sanitizedMetadata,
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
'recorded_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,27 +40,79 @@ public function getGrantedPermissions(Tenant $tenant): array
|
|||||||
* @param bool $persist Persist comparison results to tenant_permissions
|
* @param bool $persist Persist comparison results to tenant_permissions
|
||||||
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
||||||
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
||||||
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
|
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
|
||||||
|
* @return array{
|
||||||
|
* overall_status:string,
|
||||||
|
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
||||||
|
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function compare(
|
public function compare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
?array $grantedStatuses = null,
|
?array $grantedStatuses = null,
|
||||||
bool $persist = true,
|
bool $persist = true,
|
||||||
bool $liveCheck = false,
|
bool $liveCheck = false,
|
||||||
bool $useConfiguredStub = true
|
bool $useConfiguredStub = true,
|
||||||
|
?array $graphOptions = null,
|
||||||
): array {
|
): array {
|
||||||
$required = $this->getRequiredPermissions();
|
$required = $this->getRequiredPermissions();
|
||||||
|
$liveCheckMeta = [
|
||||||
|
'attempted' => false,
|
||||||
|
'succeeded' => false,
|
||||||
|
'http_status' => null,
|
||||||
|
'reason_code' => null,
|
||||||
|
];
|
||||||
|
|
||||||
$liveCheckFailed = false;
|
$liveCheckFailed = false;
|
||||||
$liveCheckDetails = null;
|
$liveCheckDetails = null;
|
||||||
|
|
||||||
// If liveCheck is requested, fetch actual permissions from Graph
|
// If liveCheck is requested, fetch actual permissions from Graph
|
||||||
if ($liveCheck && $grantedStatuses === null) {
|
if ($liveCheck && $grantedStatuses === null) {
|
||||||
$grantedStatuses = $this->fetchLivePermissions($tenant);
|
$liveCheckMeta['attempted'] = true;
|
||||||
|
|
||||||
|
$appId = null;
|
||||||
|
if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
|
||||||
|
$appId = (string) $graphOptions['client_id'];
|
||||||
|
} elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') {
|
||||||
|
$appId = (string) $tenant->graphOptions()['client_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($appId !== null) {
|
||||||
|
$liveCheckMeta['app_id'] = $appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions);
|
||||||
|
|
||||||
if (isset($grantedStatuses['__error'])) {
|
if (isset($grantedStatuses['__error'])) {
|
||||||
$liveCheckFailed = true;
|
$liveCheckFailed = true;
|
||||||
$liveCheckDetails = $grantedStatuses['__error']['details'] ?? null;
|
$liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null;
|
||||||
|
$liveCheckDetails = is_array($liveCheckError['details'] ?? null)
|
||||||
|
? $liveCheckError['details']
|
||||||
|
: (is_array($liveCheckError) ? $liveCheckError : null);
|
||||||
|
|
||||||
|
$httpStatus = $liveCheckDetails['status'] ?? null;
|
||||||
|
$liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null;
|
||||||
|
$liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode(
|
||||||
|
$liveCheckMeta['http_status'],
|
||||||
|
is_array($liveCheckDetails) ? $liveCheckDetails : null,
|
||||||
|
);
|
||||||
|
|
||||||
unset($grantedStatuses['__error']);
|
unset($grantedStatuses['__error']);
|
||||||
|
$grantedStatuses = null;
|
||||||
|
} else {
|
||||||
|
$observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0;
|
||||||
|
$liveCheckMeta['observed_permissions_count'] = $observedCount;
|
||||||
|
|
||||||
|
if ($observedCount === 0) {
|
||||||
|
// Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh.
|
||||||
|
// This prevents false "missing" findings due to partial/misconfigured verification context.
|
||||||
|
$liveCheckMeta['succeeded'] = false;
|
||||||
|
$liveCheckMeta['reason_code'] = 'permissions_inventory_empty';
|
||||||
|
$grantedStatuses = null;
|
||||||
|
} else {
|
||||||
|
$liveCheckMeta['succeeded'] = true;
|
||||||
|
$liveCheckMeta['reason_code'] = 'ok';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,16 +133,48 @@ public function compare(
|
|||||||
$hasErrors = false;
|
$hasErrors = false;
|
||||||
$checkedAt = now();
|
$checkedAt = now();
|
||||||
|
|
||||||
|
$canPersist = $persist;
|
||||||
|
|
||||||
|
if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||||
|
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||||
|
// When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot
|
||||||
|
// only if we have no stored inventory yet, so the UI can explain the failure.
|
||||||
|
$reasonCode = is_string($liveCheckMeta['reason_code'] ?? null)
|
||||||
|
? (string) $liveCheckMeta['reason_code']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$shouldPersistErrorSnapshot = in_array($reasonCode, [
|
||||||
|
'authentication_failed',
|
||||||
|
'permission_denied',
|
||||||
|
], true);
|
||||||
|
|
||||||
|
if (! $shouldPersistErrorSnapshot) {
|
||||||
|
$canPersist = false;
|
||||||
|
} else {
|
||||||
|
$hasStoredStatuses = TenantPermission::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$canPersist = ! $hasStoredStatuses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($required as $permission) {
|
foreach ($required as $permission) {
|
||||||
$key = $permission['key'];
|
$key = $permission['key'];
|
||||||
$status = $liveCheckFailed
|
$status = $liveCheckFailed
|
||||||
? 'error'
|
? 'error'
|
||||||
: ($granted[$key]['status'] ?? 'missing');
|
: ($granted[$key]['status'] ?? 'missing');
|
||||||
|
|
||||||
$details = $liveCheckFailed
|
$details = $liveCheckFailed
|
||||||
? ($liveCheckDetails ?? ['source' => 'graph_api'])
|
? array_filter([
|
||||||
|
'source' => 'graph_api',
|
||||||
|
'status' => $liveCheckMeta['http_status'],
|
||||||
|
'reason_code' => $liveCheckMeta['reason_code'],
|
||||||
|
'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null,
|
||||||
|
], fn (mixed $value): bool => $value !== null)
|
||||||
: ($granted[$key]['details'] ?? null);
|
: ($granted[$key]['details'] ?? null);
|
||||||
|
|
||||||
if ($persist) {
|
if ($canPersist) {
|
||||||
TenantPermission::updateOrCreate(
|
TenantPermission::updateOrCreate(
|
||||||
[
|
[
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -123,10 +207,36 @@ public function compare(
|
|||||||
default => 'granted',
|
default => 'granted',
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
$payload = [
|
||||||
'overall_status' => $overall,
|
'overall_status' => $overall,
|
||||||
'permissions' => $results,
|
'permissions' => $results,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($liveCheckMeta['attempted'] === true) {
|
||||||
|
$payload['live_check'] = $liveCheckMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $details
|
||||||
|
*/
|
||||||
|
private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string
|
||||||
|
{
|
||||||
|
if (is_array($details) && is_string($details['reason_code'] ?? null)) {
|
||||||
|
return (string) $details['reason_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$httpStatus === 401 => 'authentication_failed',
|
||||||
|
$httpStatus === 403 => 'permission_denied',
|
||||||
|
$httpStatus === 408 => 'dependency_unreachable',
|
||||||
|
$httpStatus === 429 => 'throttled',
|
||||||
|
is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable',
|
||||||
|
is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error',
|
||||||
|
default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -211,11 +321,11 @@ private function configuredGrantedKeys(): array
|
|||||||
*
|
*
|
||||||
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
||||||
*/
|
*/
|
||||||
private function fetchLivePermissions(Tenant $tenant): array
|
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = $this->graphClient->getServicePrincipalPermissions(
|
$response = $this->graphClient->getServicePrincipalPermissions(
|
||||||
$tenant->graphOptions()
|
$graphOptions ?? $tenant->graphOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $response->success) {
|
if (! $response->success) {
|
||||||
@ -232,6 +342,25 @@ private function fetchLivePermissions(Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$grantedPermissions = $response->data['permissions'] ?? [];
|
$grantedPermissions = $response->data['permissions'] ?? [];
|
||||||
|
$diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null;
|
||||||
|
$assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0;
|
||||||
|
$mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null;
|
||||||
|
|
||||||
|
if ($assignmentsTotal > 0 && $mappedTotal === 0) {
|
||||||
|
return [
|
||||||
|
'__error' => [
|
||||||
|
'status' => 'error',
|
||||||
|
'details' => [
|
||||||
|
'source' => 'graph_api',
|
||||||
|
'status' => $response->status,
|
||||||
|
'reason_code' => 'permission_mapping_failed',
|
||||||
|
'message' => 'Graph returned app role assignments, but the system could not map them to permission values.',
|
||||||
|
'diagnostics' => $diagnostics,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$normalized = [];
|
$normalized = [];
|
||||||
|
|
||||||
foreach ($grantedPermissions as $permission) {
|
foreach ($grantedPermissions as $permission) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user