Compare commits
1 Commits
dev
...
069-manage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31376c422e |
@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
coverage/
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -14,8 +14,8 @@ ## Active Technologies
|
||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||
- 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)
|
||||
- PostgreSQL (Sail) (069-managed-tenant-onboarding-wizard)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -35,7 +35,7 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
|
||||
- 069-managed-tenant-onboarding-wizard: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||
|
||||
|
||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
return Tenant::currentOrFail();
|
||||
return Tenant::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::currentOrFail()]);
|
||||
return collect([Tenant::current()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ChooseWorkspace extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'choose-workspace';
|
||||
|
||||
protected static ?string $title = 'Choose workspace';
|
||||
|
||||
protected string $view = 'filament.pages.choose-workspace';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->helperText('Optional. Used in URLs if set.')
|
||||
->maxLength(255)
|
||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||
->dehydrated(fn ($state) => filled($state)),
|
||||
])
|
||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
public function getWorkspaces(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Workspace::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
return Workspace::query()
|
||||
->whereIn('id', function ($query) use ($user): void {
|
||||
$query->from('workspace_memberships')
|
||||
->select('workspace_id')
|
||||
->where('user_id', $user->getKey());
|
||||
})
|
||||
->whereNull('archived_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function selectWorkspace(int $workspaceId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! empty($workspace->archived_at)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect($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();
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,6 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NoAccess extends Page
|
||||
@ -26,60 +19,4 @@ class NoAccess extends Page
|
||||
protected static ?string $title = 'No access';
|
||||
|
||||
protected string $view = 'filament.pages.no-access';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->helperText('Optional. Used in URLs if set.')
|
||||
->maxLength(255)
|
||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||
->dehydrated(fn ($state) => filled($state)),
|
||||
])
|
||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantlessOperationRunViewer extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => url()->current()),
|
||||
];
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,9 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -23,44 +21,14 @@ public static function getLabel(): string
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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 mount(): void
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -111,12 +79,6 @@ protected function handleRegistration(array $data): Model
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create($data);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal file
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal file
@ -0,0 +1,820 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Jobs\TenantOnboardingVerifyJob;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\TenantOnboardingAuditService;
|
||||
use App\Services\TenantOnboardingSessionService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantOnboardingWizard extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'tenant-onboarding';
|
||||
|
||||
protected static ?string $title = 'Tenant onboarding';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-onboarding-wizard';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $data = [];
|
||||
|
||||
public ?string $sessionId = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public ?string $currentStep = null;
|
||||
|
||||
public ?int $verificationRunId = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenantFromRequest();
|
||||
|
||||
$sessionService = app(TenantOnboardingSessionService::class);
|
||||
|
||||
if (filled(request()->query('session'))) {
|
||||
$session = $sessionService->resumeById($user, (string) request()->query('session'));
|
||||
} else {
|
||||
$session = $sessionService->startOrResume($user, $tenant);
|
||||
}
|
||||
|
||||
$this->sessionId = (string) $session->getKey();
|
||||
$this->tenantId = $session->tenant_id;
|
||||
$this->currentStep = (string) $session->current_step;
|
||||
|
||||
$this->data = array_merge($this->data, $session->payload ?? []);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->data = array_merge($this->data, [
|
||||
'name' => $tenant->name,
|
||||
'tenant_id' => $tenant->tenant_id,
|
||||
'domain' => $tenant->domain,
|
||||
'environment' => $tenant->environment,
|
||||
'app_client_id' => $tenant->app_client_id,
|
||||
'app_certificate_thumbprint' => $tenant->app_certificate_thumbprint,
|
||||
'app_notes' => $tenant->app_notes,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->form->fill($this->data);
|
||||
}
|
||||
|
||||
public function enqueueVerification(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$this->requireCapability(Capabilities::PROVIDER_RUN);
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->requireTenant();
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$run = $runs->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'tenant.rbac.verify',
|
||||
identityInputs: [
|
||||
'purpose' => 'tenant_rbac_verify',
|
||||
],
|
||||
context: [
|
||||
'operation' => [
|
||||
'type' => 'tenant.rbac.verify',
|
||||
],
|
||||
'target_scope' => [
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'entra_tenant_id' => $tenant->tenant_id,
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$this->verificationRunId = (int) $run->getKey();
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
$runs->dispatchOrFail($run, function (OperationRun $run) use ($tenant, $user): void {
|
||||
TenantOnboardingVerifyJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
});
|
||||
|
||||
Notification::make()->title('Verification queued')->success()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title('Verification already in progress')->info()->send();
|
||||
}
|
||||
|
||||
public function enqueueConnectionCheck(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$this->requireCapability(Capabilities::PROVIDER_RUN);
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->requireTenant();
|
||||
|
||||
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||
|
||||
/** @var ProviderOperationStartGate $gate */
|
||||
$gate = app(ProviderOperationStartGate::class);
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $user, $connection): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()->title('Scope busy')->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()->title('Connection check already queued')->info()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title('Connection check queued')->success()->send();
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->components([
|
||||
Wizard::make($this->getSteps())
|
||||
->startOnStep(fn (): int => $this->getStartStep())
|
||||
->submitAction('')
|
||||
->cancelAction(''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Step>
|
||||
*/
|
||||
private function getSteps(): array
|
||||
{
|
||||
$steps = [
|
||||
Step::make('Welcome')
|
||||
->id('welcome')
|
||||
->description('Requirements and what to expect.')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('welcome_copy')
|
||||
->label('')
|
||||
->content('This wizard will create or update a tenant record without making any outbound calls. You can resume at any time.'),
|
||||
])
|
||||
->afterValidation(fn (): mixed => $this->persistStep('tenant_details')),
|
||||
|
||||
Step::make('Tenant Details')
|
||||
->id('tenant_details')
|
||||
->description('Basic tenant metadata')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Display name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('environment')
|
||||
->options([
|
||||
'prod' => 'PROD',
|
||||
'dev' => 'DEV',
|
||||
'staging' => 'STAGING',
|
||||
'other' => 'Other',
|
||||
])
|
||||
->default('other')
|
||||
->required(),
|
||||
TextInput::make('tenant_id')
|
||||
->label('Tenant ID (GUID)')
|
||||
->required()
|
||||
->rule('uuid')
|
||||
->maxLength(255),
|
||||
TextInput::make('domain')
|
||||
->label('Primary domain')
|
||||
->maxLength(255),
|
||||
])
|
||||
->afterValidation(fn (): mixed => $this->handleTenantDetailsCompleted()),
|
||||
];
|
||||
|
||||
$steps = array_merge($steps, $this->credentialsRequired()
|
||||
? [$this->credentialsStep()]
|
||||
: []);
|
||||
|
||||
$steps[] = Step::make('Admin Consent & Permissions')
|
||||
->id('permissions')
|
||||
->description('Grant permissions and verify access')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('permissions_copy')
|
||||
->label('')
|
||||
->content('Next, you will grant admin consent and verify permissions. (Verification runs are implemented in the next phase.)'),
|
||||
])
|
||||
->afterValidation(fn (): mixed => $this->persistStep('verification'));
|
||||
|
||||
$steps[] = Step::make('Verification / First Run')
|
||||
->id('verification')
|
||||
->description('Finish setup and validate readiness')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('verification_copy')
|
||||
->label('')
|
||||
->content('Verification checks are enqueue-only and will appear here once implemented.'),
|
||||
])
|
||||
->afterValidation(fn (): mixed => $this->persistStep('verification'));
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
private function credentialsStep(): Step
|
||||
{
|
||||
return Step::make('App / Credentials')
|
||||
->id('credentials')
|
||||
->description('Set credentials (if required)')
|
||||
->schema([
|
||||
TextInput::make('app_client_id')
|
||||
->label('App Client ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('app_client_secret')
|
||||
->label('App Client Secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('app_certificate_thumbprint')
|
||||
->label('Certificate thumbprint')
|
||||
->maxLength(255),
|
||||
Textarea::make('app_notes')
|
||||
->label('Notes')
|
||||
->rows(3),
|
||||
Checkbox::make('acknowledge_credentials')
|
||||
->label('I understand this will store credentials encrypted and they cannot be shown again.')
|
||||
->accepted()
|
||||
->required(),
|
||||
])
|
||||
->afterValidation(fn (): mixed => $this->handleCredentialsCompleted());
|
||||
}
|
||||
|
||||
private function handleTenantDetailsCompleted(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||
|
||||
$tenant = $this->upsertTenantFromData();
|
||||
|
||||
$this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||
|
||||
$this->tenantId = (int) $tenant->getKey();
|
||||
|
||||
$nextStep = $this->credentialsRequired() ? 'credentials' : 'permissions';
|
||||
|
||||
$this->persistStep($nextStep, $tenant);
|
||||
|
||||
Notification::make()->title('Tenant details saved')->success()->send();
|
||||
}
|
||||
|
||||
private function handleCredentialsCompleted(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||
|
||||
$tenant = $this->requireTenant();
|
||||
|
||||
$secret = (string) ($this->data['app_client_secret'] ?? '');
|
||||
|
||||
$tenant->forceFill([
|
||||
'app_client_id' => $this->data['app_client_id'] ?? null,
|
||||
'app_certificate_thumbprint' => $this->data['app_certificate_thumbprint'] ?? null,
|
||||
'app_notes' => $this->data['app_notes'] ?? null,
|
||||
]);
|
||||
|
||||
if (filled($secret)) {
|
||||
$tenant->forceFill(['app_client_secret' => $secret]);
|
||||
}
|
||||
|
||||
$tenant->save();
|
||||
|
||||
if (filled($secret) && filled($tenant->app_client_id) && filled($tenant->tenant_id)) {
|
||||
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
|
||||
|
||||
app(CredentialManager::class)->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
clientId: (string) $tenant->app_client_id,
|
||||
clientSecret: (string) $secret,
|
||||
);
|
||||
}
|
||||
|
||||
if (filled($secret)) {
|
||||
$actor = auth()->user();
|
||||
|
||||
app(TenantOnboardingAuditService::class)->credentialsUpdated(
|
||||
tenant: $tenant,
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
context: [
|
||||
'app_client_id_set' => filled($tenant->app_client_id),
|
||||
'app_client_secret_set' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$this->data['app_client_secret'] = null;
|
||||
$this->form->fill($this->data);
|
||||
|
||||
$this->persistStep('permissions', $tenant);
|
||||
|
||||
Notification::make()->title('Credentials saved')->success()->send();
|
||||
}
|
||||
|
||||
private function persistStep(string $currentStep, ?Tenant $tenant = null): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
$this->requireCapability(Capabilities::TENANT_MANAGE);
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$session = $this->requireSession();
|
||||
|
||||
$service = app(TenantOnboardingSessionService::class);
|
||||
|
||||
$updated = $service->persistProgress(
|
||||
session: $session,
|
||||
currentStep: $currentStep,
|
||||
payload: $this->data,
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
$this->sessionId = (string) $updated->getKey();
|
||||
$this->tenantId = $updated->tenant_id;
|
||||
$this->currentStep = (string) $updated->current_step;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only: uses config + stored tenant_permissions.
|
||||
*
|
||||
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>,status:string}>
|
||||
*/
|
||||
public function permissionRows(): array
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$required = config('intune_permissions.permissions', []);
|
||||
$required = is_array($required) ? $required : [];
|
||||
|
||||
$granted = TenantPermission::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->get()
|
||||
->keyBy('permission_key');
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($required as $permission) {
|
||||
if (! is_array($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) ($permission['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stored = $granted->get($key);
|
||||
$status = $stored instanceof TenantPermission
|
||||
? (string) $stored->status
|
||||
: 'missing';
|
||||
|
||||
$rows[] = [
|
||||
'key' => $key,
|
||||
'type' => (string) ($permission['type'] ?? 'application'),
|
||||
'description' => isset($permission['description']) && is_string($permission['description']) ? $permission['description'] : null,
|
||||
'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [],
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function latestVerificationRunStatus(): ?string
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->where('type', 'tenant.rbac.verify')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $run->status;
|
||||
}
|
||||
|
||||
public function latestConnectionCheckRunStatus(): ?string
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$connection = $tenant->providerConnections()
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $tenant->tenant_id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->where('context->provider_connection_id', (int) $connection->getKey())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $run->status;
|
||||
}
|
||||
|
||||
public function isReadyToCompleteOnboarding(): bool
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$connection = $tenant->providerConnections()
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $tenant->tenant_id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$connectionOk = $connection instanceof ProviderConnection
|
||||
&& (string) $connection->health_status === 'ok'
|
||||
&& (string) $connection->status === 'connected';
|
||||
|
||||
$permissionsOk = collect($this->permissionRows())
|
||||
->every(fn (array $row): bool => (string) ($row['status'] ?? 'missing') === 'granted');
|
||||
|
||||
$verifyRunOk = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'tenant.rbac.verify')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', 'succeeded')
|
||||
->exists();
|
||||
|
||||
return $connectionOk && $permissionsOk && $verifyRunOk;
|
||||
}
|
||||
|
||||
private function ensureDefaultMicrosoftProviderConnection(Tenant $tenant): ProviderConnection
|
||||
{
|
||||
$existing = $tenant->providerConnections()
|
||||
->where('provider', 'microsoft')
|
||||
->where('entra_tenant_id', $tenant->tenant_id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof ProviderConnection) {
|
||||
if (! $existing->is_default) {
|
||||
$existing->makeDefault();
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return ProviderConnection::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Microsoft Graph',
|
||||
'is_default' => true,
|
||||
'status' => 'needs_consent',
|
||||
'health_status' => 'unknown',
|
||||
'scopes_granted' => [],
|
||||
'metadata' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function upsertTenantFromData(): Tenant
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenantGuid = Str::lower((string) ($this->data['tenant_id'] ?? ''));
|
||||
|
||||
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->first();
|
||||
$isNewTenant = ! $tenant instanceof Tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenant = new Tenant();
|
||||
$tenant->forceFill([
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'name' => $this->data['name'] ?? null,
|
||||
'tenant_id' => $tenantGuid,
|
||||
'domain' => $this->data['domain'] ?? null,
|
||||
'environment' => $this->data['environment'] ?? 'other',
|
||||
'onboarding_status' => 'in_progress',
|
||||
'onboarding_completed_at' => null,
|
||||
]);
|
||||
|
||||
try {
|
||||
$tenant->save();
|
||||
} catch (QueryException $exception) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$alreadyMember = $user->tenants()->whereKey($tenant->getKey())->exists();
|
||||
|
||||
if (! $alreadyMember) {
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => [
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
'created_by_user_id' => $user->getKey(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($isNewTenant && ! $alreadyMember) {
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant_membership.bootstrap_assign',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function authorizeAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenantFromRequest();
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For creating a new tenant (not yet in scope), require that the user can manage at least one tenant.
|
||||
$canManageAny = $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||
|
||||
if (! $canManageAny) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTenantFromRequest(): ?Tenant
|
||||
{
|
||||
$tenantExternalId = request()->query('tenant');
|
||||
|
||||
if (! is_string($tenantExternalId) || blank($tenantExternalId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenantExternalId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function credentialsRequired(): bool
|
||||
{
|
||||
return (bool) config('tenantpilot.onboarding.credentials_required', false);
|
||||
}
|
||||
|
||||
private function getStartStep(): int
|
||||
{
|
||||
$session = $this->requireSession();
|
||||
|
||||
$keys = $this->getStepKeys();
|
||||
$index = array_search((string) $session->current_step, $keys, true);
|
||||
|
||||
if ($index === false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return $index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getStepKeys(): array
|
||||
{
|
||||
$keys = ['welcome', 'tenant_details'];
|
||||
|
||||
if ($this->credentialsRequired()) {
|
||||
$keys[] = 'credentials';
|
||||
}
|
||||
|
||||
$keys[] = 'permissions';
|
||||
$keys[] = 'verification';
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function requireSession(): TenantOnboardingSession
|
||||
{
|
||||
if (! filled($this->sessionId)) {
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->sessionId = (string) app(TenantOnboardingSessionService::class)->startOrResume($user)->getKey();
|
||||
}
|
||||
|
||||
return TenantOnboardingSession::query()->whereKey($this->sessionId)->firstOrFail();
|
||||
}
|
||||
|
||||
private function requireTenant(): Tenant
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
abort(400, 'Tenant not initialized');
|
||||
}
|
||||
|
||||
return Tenant::query()->whereKey($this->tenantId)->firstOrFail();
|
||||
}
|
||||
|
||||
public function tenantHasClientSecret(): bool
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
|
||||
return $tenant instanceof Tenant && filled($tenant->getRawOriginal('app_client_secret'));
|
||||
}
|
||||
|
||||
public function canRunProviderOperations(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenantForAuthorization();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& app(CapabilityResolver::class)->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||
}
|
||||
|
||||
private function requireCapability(string $capability): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenantForAuthorization();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! app(CapabilityResolver::class)->can($user, $tenant, $capability)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTenantForAuthorization(): ?Tenant
|
||||
{
|
||||
if ($this->tenantId) {
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolveTenantFromRequest();
|
||||
}
|
||||
}
|
||||
@ -1,184 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,79 +0,0 @@
|
||||
<?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
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||
$data['tenant_id'] = Tenant::current()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
||||
->defaultSort('scheduled_for', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('scheduled_for')
|
||||
|
||||
@ -3,16 +3,11 @@
|
||||
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\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
@ -141,92 +136,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||
->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 = Tenant::current();
|
||||
|
||||
$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')
|
||||
->schema([
|
||||
ViewEntry::make('context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->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;
|
||||
})
|
||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
@ -894,7 +894,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->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
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -14,13 +15,11 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -100,16 +99,9 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
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));
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
->columns([
|
||||
@ -183,22 +175,29 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
$initiator = $user;
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
initiator: $user,
|
||||
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,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
@ -641,17 +640,9 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
@ -22,7 +22,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -13,7 +14,6 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -167,7 +167,7 @@ protected function getHeaderActions(): array
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -185,9 +185,18 @@ protected function getHeaderActions(): array
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
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,
|
||||
);
|
||||
|
||||
|
||||
@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->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')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
@ -31,7 +30,6 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -72,21 +70,7 @@ public static function canCreate(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
return static::userCanManageAnyTenant($user);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
@ -195,15 +179,8 @@ public static function getEloquentQuery(): Builder
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->pluck('tenants.id');
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
|
||||
@ -2,28 +2,18 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Pages\TenantOnboardingWizard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTenant extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
public function mount(): void
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
return $data;
|
||||
$this->redirect(TenantOnboardingWizard::getUrl());
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Pages\TenantOnboardingWizard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
@ -13,9 +14,12 @@ class ListTenants extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
Actions\Action::make('onboardTenant')
|
||||
->label('Onboard tenant')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->url(fn (): string => TenantOnboardingWizard::getUrl())
|
||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to onboard tenants.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Pages\TenantOnboardingWizard;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -38,6 +39,17 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('resume_onboarding')
|
||||
->label('Resume onboarding')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl().'?tenant='.$record->external_id)
|
||||
->visible(fn (Tenant $record): bool => (string) ($record->onboarding_status ?? 'not_started') !== 'completed')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
|
||||
@ -25,22 +25,11 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->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'))
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('Email'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
@ -60,13 +49,7 @@ public function table(Table $table): Table
|
||||
->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()),
|
||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateWorkspace extends CreateRecord
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWorkspace extends EditRecord
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWorkspaces extends ListRecords
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewWorkspace extends ViewRecord
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,221 +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\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,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces;
|
||||
|
||||
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||
use App\Models\Workspace;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class WorkspaceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Workspace::class;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('slug')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
Actions\EditAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListWorkspaces::route('/'),
|
||||
'create' => Pages\CreateWorkspace::route('/create'),
|
||||
'view' => Pages\ViewWorkspace::route('/{record}'),
|
||||
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
WorkspaceMembershipsRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
<?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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
<?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);
|
||||
|
||||
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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
<?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 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);
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceMember
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$workspaceParam = $request->route()?->parameter('workspace');
|
||||
|
||||
$workspace = $workspaceParam instanceof Workspace
|
||||
? $workspaceParam
|
||||
: (is_scalar($workspaceParam)
|
||||
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
|
||||
: null);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceSelected
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$routeName = $request->route()?->getName();
|
||||
|
||||
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if (str_starts_with($path, '/admin/t/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($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 $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
|
||||
|
||||
if ($workspace !== null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||
|
||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||
? $membershipQuery
|
||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||
->whereNull('workspaces.archived_at')
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||
|
||||
return new HttpResponse('', 302, ['Location' => $target]);
|
||||
}
|
||||
}
|
||||
@ -7,17 +7,11 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -89,146 +83,17 @@ public function handle(
|
||||
|
||||
$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) {
|
||||
$run = $runs->updateRun(
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $runs->updateRun(
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
@ -238,8 +103,6 @@ public function handle(
|
||||
'message' => $result->message ?? 'Health check failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||
@ -282,34 +145,4 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
||||
'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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal file
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal file
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\TenantOnboardingAuditService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantOnboardingVerifyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
TenantPermissionService $permissions,
|
||||
OperationRunService $runs,
|
||||
TenantOnboardingAuditService $audit,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$result = $permissions->compare(
|
||||
tenant: $tenant,
|
||||
grantedStatuses: null,
|
||||
persist: true,
|
||||
liveCheck: true,
|
||||
useConfiguredStub: false,
|
||||
);
|
||||
|
||||
$overall = (string) ($result['overall_status'] ?? 'error');
|
||||
|
||||
$tenant->forceFill([
|
||||
'rbac_last_checked_at' => now(),
|
||||
'rbac_last_warnings' => $overall === 'granted' ? [] : ['permissions_not_granted'],
|
||||
])->save();
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($overall === 'granted') {
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$tenant->forceFill([
|
||||
'onboarding_status' => 'completed',
|
||||
'onboarding_completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
TenantOnboardingSession::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'current_step' => 'verification',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$audit->onboardingCompleted(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
context: [
|
||||
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'tenant.rbac.verify.not_granted',
|
||||
'message' => 'Permissions are missing or could not be verified.',
|
||||
]],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
||||
$existingPolicyIds = $backupSet
|
||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||
: [];
|
||||
|
||||
@ -21,41 +21,11 @@ class OperationRun extends Model
|
||||
'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
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
@ -26,11 +26,6 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function credential(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
@ -21,20 +20,13 @@ class Tenant extends Model implements HasName
|
||||
use HasFactory;
|
||||
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 $casts = [
|
||||
'metadata' => 'array',
|
||||
'app_client_secret' => 'encrypted',
|
||||
'is_current' => 'boolean',
|
||||
'onboarding_completed_at' => 'datetime',
|
||||
'rbac_last_checked_at' => 'datetime',
|
||||
'rbac_last_setup_at' => 'datetime',
|
||||
'rbac_canary_results' => 'array',
|
||||
@ -77,16 +69,7 @@ protected static function booted(): void
|
||||
}
|
||||
|
||||
if (empty($tenant->status)) {
|
||||
$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();
|
||||
$tenant->status = 'active';
|
||||
}
|
||||
});
|
||||
|
||||
@ -101,12 +84,12 @@ protected static function booted(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant->status = self::STATUS_ARCHIVED;
|
||||
$tenant->status = 'archived';
|
||||
$tenant->saveQuietly();
|
||||
});
|
||||
|
||||
static::restored(function (Tenant $tenant) {
|
||||
$tenant->forceFill(['status' => self::STATUS_ACTIVE])->saveQuietly();
|
||||
$tenant->forceFill(['status' => 'active'])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
@ -114,12 +97,12 @@ public static function activeQuery(): Builder
|
||||
{
|
||||
return static::query()
|
||||
->whereNull('deleted_at')
|
||||
->where('status', self::STATUS_ACTIVE);
|
||||
->where('status', 'active');
|
||||
}
|
||||
|
||||
public function makeCurrent(): void
|
||||
{
|
||||
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
|
||||
if ($this->trashed() || $this->status !== 'active') {
|
||||
throw new RuntimeException('Only active tenants can be made current.');
|
||||
}
|
||||
|
||||
@ -134,7 +117,7 @@ public function makeCurrent(): void
|
||||
$this->forceFill(['is_current' => true]);
|
||||
}
|
||||
|
||||
public static function current(): ?self
|
||||
public static function current(): self
|
||||
{
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
@ -163,13 +146,6 @@ public static function current(): ?self
|
||||
->where('is_current', true)
|
||||
->first();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public static function currentOrFail(): self
|
||||
{
|
||||
$tenant = static::current();
|
||||
|
||||
if (! $tenant) {
|
||||
throw new RuntimeException('No current tenant selected.');
|
||||
}
|
||||
@ -195,9 +171,9 @@ public function memberships(): HasMany
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
public function onboardingSessions(): HasMany
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
return $this->hasMany(TenantOnboardingSession::class);
|
||||
}
|
||||
|
||||
public function roleMappings(): HasMany
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -11,82 +12,29 @@ class TenantOnboardingSession extends Model
|
||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'managed_tenant_onboarding_sessions';
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* @var array<int, 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',
|
||||
];
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'state' => 'array',
|
||||
'payload' => 'array',
|
||||
'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
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function startedByUser(): BelongsTo
|
||||
public function createdByUser(): BelongsTo
|
||||
{
|
||||
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');
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasDefaultTenant;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
@ -142,10 +141,7 @@ public function getTenants(Panel $panel): array|Collection
|
||||
return collect();
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@ -157,8 +153,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
$tenantId = null;
|
||||
|
||||
if ($this->tenantPreferencesTableExists()) {
|
||||
@ -170,7 +164,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$tenant = $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
@ -181,7 +174,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
}
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Workspace extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return HasMany<WorkspaceMembership, $this>
|
||||
*/
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceMembership::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User, $this>
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'workspace_memberships')
|
||||
->using(WorkspaceMembership::class)
|
||||
->withPivot(['id', 'role'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Tenant, $this>
|
||||
*/
|
||||
public function tenants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkspaceMembership extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -33,20 +33,8 @@ public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$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);
|
||||
|
||||
$runUrl = match (true) {
|
||||
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||
@ -54,7 +42,7 @@ public function toDatabase(object $notifiable): array
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
|
||||
])
|
||||
->getDatabaseMessage();
|
||||
}
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
@ -15,31 +14,31 @@ class OperationRunPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, OperationRun $run): Response|bool
|
||||
{
|
||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$isMember = WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
|
||||
return $isMember ? true : Response::denyAsNotFound();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -17,31 +15,15 @@ class ProviderConnectionPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
&& Gate::forUser($user)->allows('provider.view', $tenant);
|
||||
return Gate::forUser($user)->allows('provider.view', $tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$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)) {
|
||||
return false;
|
||||
}
|
||||
@ -50,40 +32,20 @@ public function view(User $user, ProviderConnection $connection): Response|bool
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
&& Gate::forUser($user)->allows('provider.manage', $tenant);
|
||||
return Gate::forUser($user)->allows('provider.manage', $tenant);
|
||||
}
|
||||
|
||||
public function update(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$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)) {
|
||||
return false;
|
||||
}
|
||||
@ -92,26 +54,13 @@ public function update(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(User $user, ProviderConnection $connection): Response|bool
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$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)) {
|
||||
return false;
|
||||
}
|
||||
@ -120,19 +69,6 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function currentWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_int($workspaceId)
|
||||
? Workspace::query()->whereKey($workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
|
||||
class WorkspaceMembershipPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
if ($this->isLastOwner($workspaceMembership)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
if ($this->isLastOwner($workspaceMembership)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function manageForWorkspace(User $user, Workspace $workspace): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
private function isLastOwner(WorkspaceMembership $membership): bool
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownerCount = WorkspaceMembership::query()
|
||||
->where('workspace_id', $membership->workspace_id)
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
return $ownerCount <= 1;
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
class WorkspacePolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Workspace $workspace): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,8 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
@ -25,36 +23,15 @@ public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
$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);
|
||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
||||
return $resolver->can($user, $tenant, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
foreach (Capabilities::all() as $capability) {
|
||||
if (str_starts_with($capability, 'workspace')) {
|
||||
$defineWorkspaceCapability($capability);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$defineTenantCapability($capability);
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\TenantOnboardingWizard;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
@ -15,7 +15,6 @@
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@ -39,36 +38,22 @@ public function panel(Panel $panel): Panel
|
||||
->path('admin')
|
||||
->login(Login::class)
|
||||
->authenticatedRoutes(function (Panel $panel): void {
|
||||
ChooseWorkspace::registerRoutes($panel);
|
||||
ChooseTenant::registerRoutes($panel);
|
||||
NoAccess::registerRoutes($panel);
|
||||
|
||||
WorkspaceResource::registerRoutes($panel);
|
||||
TenantOnboardingWizard::registerRoutes($panel);
|
||||
})
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||
->searchableTenantMenu()
|
||||
->tenantRegistration(RegisterTenant::class)
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||
fn () => view('filament.partials.workspace-switcher')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
@ -96,8 +81,6 @@ public function panel(Panel $panel): Panel
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class WorkspaceAuditLogger
|
||||
{
|
||||
public function log(
|
||||
Workspace $workspace,
|
||||
string $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
?string $resourceType = null,
|
||||
?string $resourceId = null,
|
||||
?int $actorId = null,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
): AuditLog {
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_id' => $actor?->getKey() ?? $actorId,
|
||||
'actor_email' => $actor?->email ?? $actorEmail,
|
||||
'actor_name' => $actor?->name ?? $actorName,
|
||||
'action' => $action,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -4,27 +4,39 @@
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PostLoginRedirectResolver
|
||||
{
|
||||
public function resolve(User $user): string
|
||||
{
|
||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||
$tenants = $this->getActiveTenants($user);
|
||||
|
||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||
? $membershipQuery
|
||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||
->whereNull('workspaces.archived_at')
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
if (! $hasAnyActiveMembership) {
|
||||
if ($tenants->isEmpty()) {
|
||||
return '/admin/no-access';
|
||||
}
|
||||
|
||||
return '/admin';
|
||||
if ($tenants->count() === 1) {
|
||||
/** @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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
@ -45,7 +44,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Workspace Capability Resolver
|
||||
*
|
||||
* Resolves user memberships and capabilities for a given workspace.
|
||||
* Caches results per request to avoid N+1 queries.
|
||||
*/
|
||||
class WorkspaceCapabilityResolver
|
||||
{
|
||||
private array $resolvedMemberships = [];
|
||||
|
||||
private array $loggedDenials = [];
|
||||
|
||||
public function getRole(User $user, Workspace $workspace): ?WorkspaceRole
|
||||
{
|
||||
$membership = $this->getMembership($user, $workspace);
|
||||
|
||||
if ($membership === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WorkspaceRole::tryFrom($membership['role']);
|
||||
}
|
||||
|
||||
public function can(User $user, Workspace $workspace, string $capability): bool
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException("Unknown capability: {$capability}");
|
||||
}
|
||||
|
||||
$role = $this->getRole($user, $workspace);
|
||||
|
||||
if ($role === null) {
|
||||
$this->logDenial($user, $workspace, $capability);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability);
|
||||
|
||||
if (! $allowed) {
|
||||
$this->logDenial($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
public function isMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->getMembership($user, $workspace) !== null;
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->resolvedMemberships = [];
|
||||
}
|
||||
|
||||
private function logDenial(User $user, Workspace $workspace, string $capability): void
|
||||
{
|
||||
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
|
||||
|
||||
if (isset($this->loggedDenials[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loggedDenials[$key] = true;
|
||||
|
||||
Log::warning('rbac.workspace.denied', [
|
||||
'capability' => $capability,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getMembership(User $user, Workspace $workspace): ?array
|
||||
{
|
||||
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
|
||||
|
||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
||||
$membership = WorkspaceMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->first(['role']);
|
||||
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
|
||||
return $this->resolvedMemberships[$cacheKey];
|
||||
}
|
||||
}
|
||||
@ -1,303 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use DomainException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class WorkspaceMembershipManager
|
||||
{
|
||||
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
|
||||
|
||||
public function addMember(
|
||||
Workspace $workspace,
|
||||
User $actor,
|
||||
User $member,
|
||||
string $role,
|
||||
string $source = 'manual',
|
||||
): WorkspaceMembership {
|
||||
$this->assertValidRole($role);
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||
$existing = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->role !== $role) {
|
||||
$fromRole = (string) $existing->role;
|
||||
|
||||
$this->guardLastOwnerDemotion($workspace, $existing, $role);
|
||||
|
||||
$existing->forceFill([
|
||||
'role' => $role,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'from_role' => $fromRole,
|
||||
'to_role' => $role,
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
$membership = WorkspaceMembership::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipAdd->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'role' => $role,
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
return $membership;
|
||||
});
|
||||
} 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',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
|
||||
{
|
||||
$this->assertValidRole($newRole);
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->workspace_id !== (int) $workspace->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different workspace.');
|
||||
}
|
||||
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
if ($oldRole === $newRole) {
|
||||
return $membership;
|
||||
}
|
||||
|
||||
$this->guardLastOwnerDemotion($workspace, $membership, $newRole);
|
||||
|
||||
$membership->forceFill([
|
||||
'role' => $newRole,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $membership->user_id,
|
||||
'from_role' => $oldRole,
|
||||
'to_role' => $newRole,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
return $membership->refresh();
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
workspace: $workspace,
|
||||
actor: $actor,
|
||||
targetUserId: (int) $membership->user_id,
|
||||
attemptedRole: $newRole,
|
||||
currentRole: (string) $membership->role,
|
||||
attemptedAction: 'role_change',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void
|
||||
{
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($workspace, $actor, $membership): void {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->workspace_id !== (int) $workspace->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different workspace.');
|
||||
}
|
||||
|
||||
$this->guardLastOwnerRemoval($workspace, $membership);
|
||||
|
||||
$memberUserId = (int) $membership->user_id;
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
$membership->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRemove->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => $memberUserId,
|
||||
'role' => $oldRole,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
workspace: $workspace,
|
||||
actor: $actor,
|
||||
targetUserId: (int) $membership->user_id,
|
||||
attemptedRole: (string) $membership->role,
|
||||
currentRole: (string) $membership->role,
|
||||
attemptedAction: 'remove',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function assertActorCanManage(User $actor, Workspace $workspace): void
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
|
||||
throw new DomainException('Forbidden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidRole(string $role): void
|
||||
{
|
||||
$valid = array_map(
|
||||
static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value,
|
||||
WorkspaceRole::cases(),
|
||||
);
|
||||
|
||||
if (! in_array($role, $valid, true)) {
|
||||
throw new DomainException('Invalid role.');
|
||||
}
|
||||
}
|
||||
|
||||
private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($newRole === WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owners = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
if ($owners <= 1) {
|
||||
throw new DomainException('You cannot demote the last remaining owner.');
|
||||
}
|
||||
}
|
||||
|
||||
private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owners = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
if ($owners <= 1) {
|
||||
throw new DomainException('You cannot remove the last remaining owner.');
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
|
||||
/**
|
||||
* Workspace Role to Capability Mapping (Single Source of Truth)
|
||||
*
|
||||
* This class defines which capabilities each workspace role has.
|
||||
* All capability strings MUST be references from the Capabilities registry.
|
||||
*/
|
||||
class WorkspaceRoleCapabilityMap
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
private static array $roleCapabilities = [
|
||||
WorkspaceRole::Owner->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGE,
|
||||
Capabilities::WORKSPACE_ARCHIVE,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
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 => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
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 => [
|
||||
Capabilities::WORKSPACE_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 => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getCapabilities(WorkspaceRole|string $role): array
|
||||
{
|
||||
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
|
||||
|
||||
return self::$roleCapabilities[$roleValue] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function rolesWithCapability(string $capability): array
|
||||
{
|
||||
$roles = [];
|
||||
|
||||
foreach (self::$roleCapabilities as $role => $capabilities) {
|
||||
if (in_array($capability, $capabilities, true)) {
|
||||
$roles[] = $role;
|
||||
}
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
public static function hasCapability(WorkspaceRole|string $role, string $capability): bool
|
||||
{
|
||||
return in_array($capability, self::getCapabilities($role), true);
|
||||
}
|
||||
}
|
||||
@ -6,25 +6,6 @@
|
||||
|
||||
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
|
||||
{
|
||||
return 'directoryGroups';
|
||||
|
||||
@ -409,20 +409,7 @@ private function shouldApplySelectFallback(GraphResponse $graphResponse, array $
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
$context = $this->resolveContext($options);
|
||||
$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, '/');
|
||||
$endpoint = 'organization';
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
$fullPath = $this->buildFullPath($endpoint);
|
||||
|
||||
@ -492,27 +479,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
|
||||
// First, get the service principal object by clientId (appId)
|
||||
$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, '/');
|
||||
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
|
||||
|
||||
$this->logger->logRequest('get_service_principal', [
|
||||
'endpoint' => $endpoint,
|
||||
'client_id' => $clientId,
|
||||
'tenant' => $context['tenant'],
|
||||
'method' => 'GET',
|
||||
'full_path' => $this->buildFullPath($endpoint),
|
||||
'full_path' => $endpoint,
|
||||
'client_request_id' => $clientRequestId,
|
||||
]);
|
||||
|
||||
@ -554,30 +528,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
}
|
||||
|
||||
// Now get the app role assignments (application permissions)
|
||||
$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, '/');
|
||||
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
|
||||
|
||||
$this->logger->logRequest('get_app_role_assignments', [
|
||||
'endpoint' => $assignmentsEndpoint,
|
||||
'service_principal_id' => $servicePrincipalId,
|
||||
'tenant' => $context['tenant'],
|
||||
'method' => 'GET',
|
||||
'full_path' => $this->buildFullPath($assignmentsEndpoint),
|
||||
'full_path' => $assignmentsEndpoint,
|
||||
'client_request_id' => $clientRequestId,
|
||||
]);
|
||||
|
||||
@ -587,68 +545,29 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
action: 'get_service_principal_permissions',
|
||||
response: $assignmentsResponse,
|
||||
transform: function (array $json) use ($context) {
|
||||
$assignments = is_array($json['value'] ?? null) ? $json['value'] : [];
|
||||
$assignmentsTotal = count($assignments);
|
||||
$assignments = $json['value'] ?? [];
|
||||
$permissions = [];
|
||||
|
||||
// Get Microsoft Graph service principal to map role IDs to permission names
|
||||
$graphSpEndpoint = $this->contracts->probePath(
|
||||
'service_principal_by_app_id',
|
||||
['{appId}' => '00000003-0000-0000-c000-000000000000'],
|
||||
);
|
||||
|
||||
$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'] : [];
|
||||
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
|
||||
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
|
||||
$graphSps = $graphSpResponse->json('value', []);
|
||||
$appRoles = $graphSps[0]['appRoles'] ?? [];
|
||||
|
||||
// Map role IDs to permission names
|
||||
$roleMap = [];
|
||||
foreach ($appRoles as $role) {
|
||||
$roleId = $role['id'] ?? null;
|
||||
$value = $role['value'] ?? null;
|
||||
|
||||
if (! is_string($roleId) || $roleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleMap[strtolower($roleId)] = $value;
|
||||
$roleMap[$role['id']] = $role['value'];
|
||||
}
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$roleId = $assignment['appRoleId'] ?? null;
|
||||
|
||||
if (! is_string($roleId) || $roleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoleId = strtolower($roleId);
|
||||
|
||||
if (isset($roleMap[$normalizedRoleId])) {
|
||||
$permissions[] = $roleMap[$normalizedRoleId];
|
||||
if ($roleId && isset($roleMap[$roleId])) {
|
||||
$permissions[] = $roleMap[$roleId];
|
||||
}
|
||||
}
|
||||
|
||||
$permissions = array_values(array_unique($permissions));
|
||||
|
||||
return [
|
||||
'permissions' => $permissions,
|
||||
'diagnostics' => [
|
||||
'assignments_total' => $assignmentsTotal,
|
||||
'mapped_total' => count($permissions),
|
||||
'graph_roles_total' => count($roleMap),
|
||||
],
|
||||
];
|
||||
return ['permissions' => $permissions];
|
||||
},
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class AuditLogger
|
||||
@ -23,10 +22,6 @@ public function log(
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'actor_id' => $actorId,
|
||||
@ -36,7 +31,7 @@ public function log(
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'metadata' => $metadata + $context,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -40,79 +40,27 @@ public function getGrantedPermissions(Tenant $tenant): array
|
||||
* @param bool $persist Persist comparison results to tenant_permissions
|
||||
* @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 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}
|
||||
* }
|
||||
* @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}>}
|
||||
*/
|
||||
public function compare(
|
||||
Tenant $tenant,
|
||||
?array $grantedStatuses = null,
|
||||
bool $persist = true,
|
||||
bool $liveCheck = false,
|
||||
bool $useConfiguredStub = true,
|
||||
?array $graphOptions = null,
|
||||
bool $useConfiguredStub = true
|
||||
): array {
|
||||
$required = $this->getRequiredPermissions();
|
||||
$liveCheckMeta = [
|
||||
'attempted' => false,
|
||||
'succeeded' => false,
|
||||
'http_status' => null,
|
||||
'reason_code' => null,
|
||||
];
|
||||
|
||||
$liveCheckFailed = false;
|
||||
$liveCheckDetails = null;
|
||||
|
||||
// If liveCheck is requested, fetch actual permissions from Graph
|
||||
if ($liveCheck && $grantedStatuses === null) {
|
||||
$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);
|
||||
$grantedStatuses = $this->fetchLivePermissions($tenant);
|
||||
|
||||
if (isset($grantedStatuses['__error'])) {
|
||||
$liveCheckFailed = true;
|
||||
$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,
|
||||
);
|
||||
|
||||
$liveCheckDetails = $grantedStatuses['__error']['details'] ?? null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,29 +81,16 @@ public function compare(
|
||||
$hasErrors = false;
|
||||
$checkedAt = now();
|
||||
|
||||
$canPersist = $persist;
|
||||
|
||||
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||
$canPersist = false;
|
||||
}
|
||||
|
||||
foreach ($required as $permission) {
|
||||
$key = $permission['key'];
|
||||
$status = $liveCheckFailed
|
||||
? 'error'
|
||||
: ($granted[$key]['status'] ?? 'missing');
|
||||
|
||||
$details = $liveCheckFailed
|
||||
? 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)
|
||||
? ($liveCheckDetails ?? ['source' => 'graph_api'])
|
||||
: ($granted[$key]['details'] ?? null);
|
||||
|
||||
if ($canPersist) {
|
||||
if ($persist) {
|
||||
TenantPermission::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@ -188,36 +123,10 @@ public function compare(
|
||||
default => 'granted',
|
||||
};
|
||||
|
||||
$payload = [
|
||||
return [
|
||||
'overall_status' => $overall,
|
||||
'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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -302,11 +211,11 @@ private function configuredGrantedKeys(): array
|
||||
*
|
||||
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
||||
*/
|
||||
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
|
||||
private function fetchLivePermissions(Tenant $tenant): array
|
||||
{
|
||||
try {
|
||||
$response = $this->graphClient->getServicePrincipalPermissions(
|
||||
$graphOptions ?? $tenant->graphOptions()
|
||||
$tenant->graphOptions()
|
||||
);
|
||||
|
||||
if (! $response->success) {
|
||||
@ -323,25 +232,6 @@ private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = nul
|
||||
}
|
||||
|
||||
$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 = [];
|
||||
|
||||
foreach ($grantedPermissions as $permission) {
|
||||
|
||||
@ -1,389 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
class TenantRequiredPermissionsViewModelBuilder
|
||||
{
|
||||
/**
|
||||
* @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
|
||||
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
|
||||
* @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
|
||||
* @phpstan-type ViewModel array{
|
||||
* tenant: array{id:int,external_id:string,name:string},
|
||||
* overview: array{
|
||||
* overall: string,
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* feature_impacts: array<int, FeatureImpact>
|
||||
* },
|
||||
* permissions: array<int, TenantPermissionRow>,
|
||||
* filters: FilterState,
|
||||
* copy: array{application:string,delegated:string}
|
||||
* }
|
||||
*/
|
||||
public function __construct(private readonly TenantPermissionService $permissionService) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return ViewModel
|
||||
*/
|
||||
public function build(Tenant $tenant, array $filters = []): array
|
||||
{
|
||||
$comparison = $this->permissionService->compare(
|
||||
$tenant,
|
||||
persist: false,
|
||||
liveCheck: false,
|
||||
useConfiguredStub: false,
|
||||
);
|
||||
|
||||
/** @var array<int, TenantPermissionRow> $allPermissions */
|
||||
$allPermissions = collect($comparison['permissions'] ?? [])
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$state = self::normalizeFilterState($filters);
|
||||
|
||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||
|
||||
return [
|
||||
'tenant' => [
|
||||
'id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) $tenant->external_id,
|
||||
'name' => (string) $tenant->name,
|
||||
],
|
||||
'overview' => [
|
||||
'overall' => self::deriveOverallStatus($allPermissions),
|
||||
'counts' => self::deriveCounts($allPermissions),
|
||||
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||
],
|
||||
'permissions' => $filteredPermissions,
|
||||
'filters' => $state,
|
||||
'copy' => [
|
||||
'application' => self::deriveCopyPayload($allPermissions, 'application', $state['features']),
|
||||
'delegated' => self::deriveCopyPayload($allPermissions, 'delegated', $state['features']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
*/
|
||||
public static function deriveOverallStatus(array $permissions): string
|
||||
{
|
||||
$hasMissingApplication = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||
);
|
||||
|
||||
if ($hasMissingApplication) {
|
||||
return VerificationReportOverall::Blocked->value;
|
||||
}
|
||||
|
||||
$hasErrors = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'error',
|
||||
);
|
||||
|
||||
$hasMissingDelegated = collect($permissions)->contains(
|
||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||
);
|
||||
|
||||
if ($hasErrors || $hasMissingDelegated) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
return VerificationReportOverall::Ready->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
||||
*/
|
||||
public static function deriveCounts(array $permissions): array
|
||||
{
|
||||
$counts = [
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 0,
|
||||
'error' => 0,
|
||||
];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$counts['missing_delegated'] += 1;
|
||||
} else {
|
||||
$counts['missing_application'] += 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'granted') {
|
||||
$counts['present'] += 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'error') {
|
||||
$counts['error'] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, FeatureImpact>
|
||||
*/
|
||||
public static function deriveFeatureImpacts(array $permissions): array
|
||||
{
|
||||
/** @var array<string, FeatureImpact> $impacts */
|
||||
$impacts = [];
|
||||
|
||||
foreach ($permissions as $row) {
|
||||
$features = array_values(array_unique($row['features'] ?? []));
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if (! isset($impacts[$feature])) {
|
||||
$impacts[$feature] = [
|
||||
'feature' => $feature,
|
||||
'missing' => 0,
|
||||
'required_application' => 0,
|
||||
'required_delegated' => 0,
|
||||
'blocked' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) === 'delegated') {
|
||||
$impacts[$feature]['required_delegated'] += 1;
|
||||
} else {
|
||||
$impacts[$feature]['required_application'] += 1;
|
||||
}
|
||||
|
||||
if (($row['status'] ?? null) === 'missing') {
|
||||
$impacts[$feature]['missing'] += 1;
|
||||
|
||||
if (($row['type'] ?? null) === 'application') {
|
||||
$impacts[$feature]['blocked'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$values = array_values($impacts);
|
||||
|
||||
usort($values, static function (array $a, array $b): int {
|
||||
$blocked = (int) ($b['blocked'] <=> $a['blocked']);
|
||||
if ($blocked !== 0) {
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
$missing = (int) (($b['missing'] ?? 0) <=> ($a['missing'] ?? 0));
|
||||
if ($missing !== 0) {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['feature'] ?? ''), (string) ($b['feature'] ?? ''));
|
||||
});
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy payload semantics:
|
||||
* - Always Missing-only
|
||||
* - Always Type fixed by button (application vs delegated)
|
||||
* - Respects Feature filter only
|
||||
* - Ignores Search
|
||||
*
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
* @param array<int, string> $featureFilter
|
||||
*/
|
||||
public static function deriveCopyPayload(array $permissions, string $type, array $featureFilter = []): string
|
||||
{
|
||||
$featureFilter = array_values(array_unique(array_filter(array_map('strval', $featureFilter))));
|
||||
|
||||
$payload = collect($permissions)
|
||||
->filter(function (array $row) use ($type, $featureFilter): bool {
|
||||
if (($row['status'] ?? null) !== 'missing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($row['type'] ?? null) !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($featureFilter === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($featureFilter, $rowFeatures)) > 0;
|
||||
})
|
||||
->pluck('key')
|
||||
->map(fn (mixed $key): string => (string) $key)
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return implode("\n", $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $permissions
|
||||
* @return array<int, TenantPermissionRow>
|
||||
*/
|
||||
public static function applyFilterState(array $permissions, array $state): array
|
||||
{
|
||||
$status = $state['status'] ?? 'missing';
|
||||
$type = $state['type'] ?? 'all';
|
||||
$features = $state['features'] ?? [];
|
||||
$search = $state['search'] ?? '';
|
||||
|
||||
$search = is_string($search) ? trim($search) : '';
|
||||
$searchLower = strtolower($search);
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
$filtered = collect($permissions)
|
||||
->filter(function (array $row) use ($status, $type, $features): bool {
|
||||
$rowStatus = $row['status'] ?? null;
|
||||
$rowType = $row['type'] ?? null;
|
||||
|
||||
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($status === 'present' && $rowStatus !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($type !== 'all' && $rowType !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($features === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$rowFeatures = $row['features'] ?? [];
|
||||
|
||||
return count(array_intersect($features, $rowFeatures)) > 0;
|
||||
})
|
||||
->when($searchLower !== '', function ($collection) use ($searchLower) {
|
||||
return $collection->filter(function (array $row) use ($searchLower): bool {
|
||||
$key = strtolower((string) ($row['key'] ?? ''));
|
||||
$description = strtolower((string) ($row['description'] ?? ''));
|
||||
|
||||
return str_contains($key, $searchLower) || ($description !== '' && str_contains($description, $searchLower));
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
usort($filtered, static function (array $a, array $b): int {
|
||||
$weight = static function (array $row): int {
|
||||
return match ($row['status'] ?? null) {
|
||||
'missing' => 0,
|
||||
'error' => 1,
|
||||
default => 2,
|
||||
};
|
||||
};
|
||||
|
||||
$cmp = $weight($a) <=> $weight($b);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
});
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return FilterState
|
||||
*/
|
||||
public static function normalizeFilterState(array $filters): array
|
||||
{
|
||||
$status = (string) ($filters['status'] ?? 'missing');
|
||||
$type = (string) ($filters['type'] ?? 'all');
|
||||
$features = $filters['features'] ?? [];
|
||||
$search = (string) ($filters['search'] ?? '');
|
||||
|
||||
if (! in_array($status, ['missing', 'present', 'all'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! in_array($type, ['application', 'delegated', 'all'], true)) {
|
||||
$type = 'all';
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'type' => $type,
|
||||
'features' => $features,
|
||||
'search' => $search,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return TenantPermissionRow
|
||||
*/
|
||||
private static function normalizePermissionRow(array $row): array
|
||||
{
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
$type = (string) ($row['type'] ?? 'application');
|
||||
$description = $row['description'] ?? null;
|
||||
$features = $row['features'] ?? [];
|
||||
$status = (string) ($row['status'] ?? 'missing');
|
||||
$details = $row['details'] ?? null;
|
||||
|
||||
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||
$type = 'application';
|
||||
}
|
||||
|
||||
if (! is_string($description) || $description === '') {
|
||||
$description = null;
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! is_array($details)) {
|
||||
$details = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'features' => $features,
|
||||
'status' => $status,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||
use App\Services\Operations\BulkIdempotencyFingerprint;
|
||||
@ -61,19 +60,12 @@ public function ensureRun(
|
||||
array $inputs,
|
||||
?User $initiator = null
|
||||
): OperationRun {
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
|
||||
}
|
||||
|
||||
$hash = $this->calculateHash($tenant->id, $type, $inputs);
|
||||
|
||||
// Idempotency Check (Fast Path)
|
||||
// We check specific status to match the partial unique index
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
@ -86,7 +78,6 @@ public function ensureRun(
|
||||
// Create new run (race-safe via partial unique index)
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
@ -106,7 +97,6 @@ public function ensureRun(
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
@ -126,19 +116,12 @@ public function ensureRunWithIdentity(
|
||||
array $context,
|
||||
?User $initiator = null
|
||||
): OperationRun {
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
|
||||
}
|
||||
|
||||
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
||||
|
||||
// Idempotency Check (Fast Path)
|
||||
// We check specific status to match the partial unique index
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
@ -151,7 +134,6 @@ public function ensureRunWithIdentity(
|
||||
// Create new run (race-safe via partial unique index)
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
@ -171,7 +153,6 @@ public function ensureRunWithIdentity(
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
@ -246,59 +227,6 @@ public function enqueueBulkOperation(
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function ensureWorkspaceRunWithIdentity(
|
||||
Workspace $workspace,
|
||||
string $type,
|
||||
array $identityInputs,
|
||||
array $context,
|
||||
?User $initiator = null,
|
||||
): OperationRun {
|
||||
$hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs);
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereNull('tenant_id')
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
'type' => $type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => $hash,
|
||||
'context' => $context,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereNull('tenant_id')
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateRun(
|
||||
OperationRun $run,
|
||||
string $status,
|
||||
@ -590,15 +518,6 @@ protected function calculateHash(int $tenantId, string $type, array $inputs): st
|
||||
return hash('sha256', $tenantId.'|'.$type.'|'.$json);
|
||||
}
|
||||
|
||||
protected function calculateWorkspaceHash(int $workspaceId, string $type, array $inputs): string
|
||||
{
|
||||
$normalizedInputs = $this->normalizeInputs($inputs);
|
||||
|
||||
$json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize inputs for stable identity hashing.
|
||||
*
|
||||
|
||||
@ -74,21 +74,4 @@ public function upsertClientSecretCredential(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function updateClientIdPreservingSecret(ProviderConnection $connection, string $clientId): ProviderCredential
|
||||
{
|
||||
$clientId = trim($clientId);
|
||||
|
||||
if ($clientId === '') {
|
||||
throw new InvalidArgumentException('client_id is required.');
|
||||
}
|
||||
|
||||
$existing = $this->getClientCredentials($connection);
|
||||
|
||||
return $this->upsertClientSecretCredential(
|
||||
connection: $connection,
|
||||
clientId: $clientId,
|
||||
clientSecret: (string) $existing['client_secret'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Services/TenantOnboardingAuditService.php
Normal file
68
app/Services/TenantOnboardingAuditService.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActions;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class TenantOnboardingAuditService
|
||||
{
|
||||
public function __construct(public AuditLogger $auditLogger)
|
||||
{
|
||||
}
|
||||
|
||||
public function credentialsUpdated(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
|
||||
{
|
||||
$context = $this->sanitizeContext($context);
|
||||
|
||||
return $this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActions::TENANT_ONBOARDING_CREDENTIALS_UPDATED,
|
||||
context: $context,
|
||||
actorId: $actor?->id,
|
||||
actorEmail: $actor?->email,
|
||||
actorName: $actor?->name,
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
public function onboardingCompleted(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
|
||||
{
|
||||
$context = $this->sanitizeContext($context);
|
||||
|
||||
return $this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActions::TENANT_ONBOARDING_COMPLETED,
|
||||
context: $context,
|
||||
actorId: $actor?->id,
|
||||
actorEmail: $actor?->email,
|
||||
actorName: $actor?->name,
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeContext(array $context): array
|
||||
{
|
||||
$keysToStrip = [
|
||||
'secret',
|
||||
'client_secret',
|
||||
'app_client_secret',
|
||||
'app_secret',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
];
|
||||
|
||||
return Arr::except($context, $keysToStrip);
|
||||
}
|
||||
}
|
||||
142
app/Services/TenantOnboardingSessionService.php
Normal file
142
app/Services/TenantOnboardingSessionService.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantOnboardingSessionService
|
||||
{
|
||||
/**
|
||||
* Start a new onboarding session, or resume an existing active session.
|
||||
*/
|
||||
public function startOrResume(User $user, ?Tenant $tenant = null): TenantOnboardingSession
|
||||
{
|
||||
if ($tenant instanceof Tenant) {
|
||||
$existing = TenantOnboardingSession::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof TenantOnboardingSession) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return TenantOnboardingSession::query()->create([
|
||||
'tenant_id' => $tenant?->getKey(),
|
||||
'created_by_user_id' => $user->getKey(),
|
||||
'status' => 'active',
|
||||
'current_step' => 'welcome',
|
||||
'payload' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resumeById(User $user, string $sessionId): TenantOnboardingSession
|
||||
{
|
||||
$session = TenantOnboardingSession::query()->whereKey($sessionId)->firstOrFail();
|
||||
|
||||
if ((int) $session->created_by_user_id !== (int) $user->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist wizard progress + non-secret payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function persistProgress(TenantOnboardingSession $session, string $currentStep, array $payload, ?Tenant $tenant = null): TenantOnboardingSession
|
||||
{
|
||||
$payload = $this->sanitizePayload($payload);
|
||||
|
||||
return DB::transaction(function () use ($session, $currentStep, $payload, $tenant): TenantOnboardingSession {
|
||||
$session->forceFill([
|
||||
'current_step' => $currentStep,
|
||||
'payload' => array_merge($session->payload ?? [], $payload),
|
||||
]);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$session->tenant()->associate($tenant);
|
||||
}
|
||||
|
||||
try {
|
||||
$session->save();
|
||||
} catch (QueryException $exception) {
|
||||
// If another active session already exists for the tenant, resume it.
|
||||
if (($tenant instanceof Tenant) && $this->isActiveSessionUniqueViolation($exception)) {
|
||||
$existing = TenantOnboardingSession::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof TenantOnboardingSession) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitizePayload(array $payload): array
|
||||
{
|
||||
$forbiddenKeys = [
|
||||
'app_client_secret',
|
||||
'client_secret',
|
||||
'secret',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'password',
|
||||
];
|
||||
|
||||
return $this->forgetKeysRecursive($payload, $forbiddenKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, string> $forbiddenKeys
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function forgetKeysRecursive(array $payload, array $forbiddenKeys): array
|
||||
{
|
||||
foreach ($forbiddenKeys as $key) {
|
||||
Arr::forget($payload, $key);
|
||||
}
|
||||
|
||||
foreach ($payload as $key => $value) {
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[$key] = $this->forgetKeysRecursive($value, $forbiddenKeys);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function isActiveSessionUniqueViolation(QueryException $exception): bool
|
||||
{
|
||||
$message = Str::lower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'tenant_onboarding_sessions_active_unique')
|
||||
|| str_contains($message, 'unique') && str_contains($message, 'tenant_onboarding_sessions');
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class StartVerification
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderOperationStartGate $providers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start (or dedupe) a provider-connection verification run.
|
||||
*
|
||||
* @param array<string, mixed> $extraContext
|
||||
*/
|
||||
public function providerConnectionCheck(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
User $initiator,
|
||||
array $extraContext = [],
|
||||
): ProviderOperationStartResult {
|
||||
if (! $initiator->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
return $this->providers->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: $extraContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class VerificationCheckAcknowledgementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkspaceAuditLogger $audit,
|
||||
) {}
|
||||
|
||||
public function acknowledge(
|
||||
Tenant $tenant,
|
||||
OperationRun $run,
|
||||
string $checkKey,
|
||||
string $ackReason,
|
||||
?string $expiresAt,
|
||||
User $actor,
|
||||
): VerificationCheckAcknowledgement {
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant);
|
||||
|
||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$checkKey = trim($checkKey);
|
||||
if ($checkKey === '') {
|
||||
throw new InvalidArgumentException('check_key is required.');
|
||||
}
|
||||
|
||||
$ackReason = trim($ackReason);
|
||||
if ($ackReason === '') {
|
||||
throw new InvalidArgumentException('ack_reason is required.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ackReason) > 160) {
|
||||
throw new InvalidArgumentException('ack_reason must be at most 160 characters.');
|
||||
}
|
||||
|
||||
$report = $this->reportForRun($run);
|
||||
$check = $this->findCheckByKey($report, $checkKey);
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) {
|
||||
throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.');
|
||||
}
|
||||
|
||||
$reasonCode = $check['reason_code'] ?? null;
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
throw new InvalidArgumentException('Check reason_code is required.');
|
||||
}
|
||||
|
||||
$expiresAtParsed = null;
|
||||
|
||||
if ($expiresAt !== null && trim($expiresAt) !== '') {
|
||||
try {
|
||||
$expiresAtParsed = CarbonImmutable::parse($expiresAt);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException('expires_at must be a valid date-time.');
|
||||
}
|
||||
|
||||
if ($expiresAtParsed->isBefore(CarbonImmutable::now())) {
|
||||
throw new InvalidArgumentException('expires_at must be in the future.');
|
||||
}
|
||||
}
|
||||
|
||||
$acknowledgedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$ack = VerificationCheckAcknowledgement::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'check_key' => $checkKey,
|
||||
'ack_reason' => $ackReason,
|
||||
'expires_at' => $expiresAtParsed,
|
||||
'acknowledged_at' => $acknowledgedAt,
|
||||
'acknowledged_by_user_id' => (int) $actor->getKey(),
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
$ack = VerificationCheckAcknowledgement::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->where('check_key', $checkKey)
|
||||
->first();
|
||||
|
||||
if (! $ack instanceof VerificationCheckAcknowledgement) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
if ($ack->wasRecentlyCreated) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null) {
|
||||
$this->audit->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCheckAcknowledged->value,
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'report_id' => (int) $run->getKey(),
|
||||
'flow' => (string) $run->type,
|
||||
'check_key' => $checkKey,
|
||||
'reason_code' => $reasonCode,
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reportForRun(OperationRun $run): array
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
if (! is_array($report)) {
|
||||
throw new InvalidArgumentException('Verification report is missing.');
|
||||
}
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
throw new InvalidArgumentException('Verification report is invalid.');
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findCheckByKey(array $report, string $checkKey): array
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($check['key'] ?? null) === $checkKey) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Check not found in verification report.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,6 @@
|
||||
|
||||
enum AuditActionId: string
|
||||
{
|
||||
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
||||
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
||||
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
||||
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
||||
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
||||
|
||||
case TenantMembershipAdd = 'tenant_membership.add';
|
||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||
@ -22,12 +16,4 @@ enum AuditActionId: string
|
||||
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
}
|
||||
|
||||
9
app/Support/Audit/AuditActions.php
Normal file
9
app/Support/Audit/AuditActions.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
final class AuditActions
|
||||
{
|
||||
public const TENANT_ONBOARDING_CREDENTIALS_UPDATED = 'tenant.onboarding.credentials.updated';
|
||||
public const TENANT_ONBOARDING_COMPLETED = 'tenant.onboarding.completed';
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
final class AuditContextSanitizer
|
||||
{
|
||||
private const REDACTED = '[REDACTED]';
|
||||
|
||||
public static function sanitize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && self::shouldRedactKey($key)) {
|
||||
$sanitized[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$key] = self::sanitize($item);
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return self::sanitizeString($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function shouldRedactKey(string $key): bool
|
||||
{
|
||||
$key = strtolower(trim($key));
|
||||
|
||||
return str_contains($key, 'token')
|
||||
|| str_contains($key, 'secret')
|
||||
|| str_contains($key, 'password')
|
||||
|| str_contains($key, 'authorization')
|
||||
|| str_contains($key, 'private_key')
|
||||
|| str_contains($key, 'client_secret');
|
||||
}
|
||||
|
||||
private static function sanitizeString(string $value): string
|
||||
{
|
||||
$candidate = trim($value);
|
||||
|
||||
if ($candidate === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -15,37 +15,6 @@ class Capabilities
|
||||
*/
|
||||
private static ?array $all = null;
|
||||
|
||||
// Workspaces
|
||||
public const WORKSPACE_VIEW = 'workspace.view';
|
||||
|
||||
public const WORKSPACE_MANAGE = 'workspace.manage';
|
||||
|
||||
public const WORKSPACE_ARCHIVE = 'workspace.archive';
|
||||
|
||||
// Workspace memberships
|
||||
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
|
||||
|
||||
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
||||
|
||||
// Managed tenant onboarding
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY = 'workspace_managed_tenant.onboard.identify';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW = 'workspace_managed_tenant.onboard.connection.view';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE = 'workspace_managed_tenant.onboard.connection.manage';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START = 'workspace_managed_tenant.onboard.verification.start';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.inventory_sync';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.policy_sync';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP = 'workspace_managed_tenant.onboard.bootstrap.backup_bootstrap';
|
||||
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE = 'workspace_managed_tenant.onboard.activate';
|
||||
|
||||
// Tenants
|
||||
public const TENANT_VIEW = 'tenant.view';
|
||||
|
||||
@ -61,9 +30,6 @@ class Capabilities
|
||||
// Findings
|
||||
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||
|
||||
// Verification
|
||||
public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge';
|
||||
|
||||
// Tenant memberships
|
||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Auth;
|
||||
|
||||
enum WorkspaceRole: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Manager = 'manager';
|
||||
case Operator = 'operator';
|
||||
case Readonly = 'readonly';
|
||||
}
|
||||
@ -36,10 +36,6 @@ final class BadgeCatalog
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -28,8 +28,4 @@ enum BadgeDomain: string
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
case ProviderConnectionStatus = 'provider_connection.status';
|
||||
case ProviderConnectionHealth = 'provider_connection.health';
|
||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
||||
case VerificationCheckStatus = 'verification_check_status';
|
||||
case VerificationCheckSeverity = 'verification_check_severity';
|
||||
case VerificationReportOverall = 'verification_report_overall';
|
||||
}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ManagedTenantOnboardingVerificationStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_started' => new BadgeSpec('Not started', 'gray', 'heroicon-m-minus-circle'),
|
||||
'in_progress' => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
||||
'needs_attention' => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||
'ready' => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckSeverity;
|
||||
|
||||
final class VerificationCheckSeverityBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
|
||||
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
|
||||
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
|
||||
final class VerificationCheckStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
final class VerificationReportOverallBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Links;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
|
||||
final class RequiredPermissionsLinks
|
||||
{
|
||||
private const ADMIN_CONSENT_GUIDE_URL = 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
|
||||
{
|
||||
$base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id));
|
||||
|
||||
if ($filters === []) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$query = http_build_query($filters);
|
||||
|
||||
return $query !== '' ? "{$base}?{$query}" : $base;
|
||||
}
|
||||
|
||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
return TenantResource::adminConsentUrl($tenant);
|
||||
}
|
||||
|
||||
public static function adminConsentGuideUrl(): string
|
||||
{
|
||||
return self::ADMIN_CONSENT_GUIDE_URL;
|
||||
}
|
||||
|
||||
public static function adminConsentPrimaryUrl(Tenant $tenant): string
|
||||
{
|
||||
return self::adminConsentUrl($tenant) ?? self::adminConsentGuideUrl();
|
||||
}
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Navigation\NavigationBuilder;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureFilamentTenantSelected
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
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) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $user instanceof HasTenants) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $panel->hasTenancy()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
$tenant = $panel->getTenant($tenantParameter);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($path, '/admin/w/')
|
||||
|| str_starts_with($path, '/admin/workspaces')
|
||||
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|
||||
) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (filled(Filament::getTenant())) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenant = null;
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
try {
|
||||
$tenant = Tenant::current();
|
||||
} catch (\RuntimeException) {
|
||||
$tenant = null;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
$tenant = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->withTrashed()->first();
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
Filament::setTenant($tenant, true);
|
||||
}
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
{
|
||||
if (! $panel->hasTenancy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filled(Filament::getTenant())) {
|
||||
$panel->navigation(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$panel->navigation(function (): NavigationBuilder {
|
||||
return app(NavigationBuilder::class)
|
||||
->item(
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,7 @@ public static function labels(): array
|
||||
'restore_run.restore' => 'Restore restore runs',
|
||||
'restore_run.force_delete' => 'Force delete restore runs',
|
||||
'tenant.sync' => 'Tenant sync',
|
||||
'tenant.rbac.verify' => 'Tenant RBAC verification',
|
||||
'policy_version.prune' => 'Prune policy versions',
|
||||
'policy_version.restore' => 'Restore policy versions',
|
||||
'policy_version.force_delete' => 'Delete policy versions',
|
||||
@ -57,6 +58,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
return match (trim($operationType)) {
|
||||
'policy.sync', 'policy.sync_one' => 90,
|
||||
'provider.connection.check' => 30,
|
||||
'tenant.rbac.verify' => 60,
|
||||
'policy.export' => 120,
|
||||
'inventory.sync' => 180,
|
||||
'compliance.snapshot' => 180,
|
||||
|
||||
@ -21,13 +21,6 @@ public static function index(Tenant $tenant): string
|
||||
return OperationRunResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
public static function tenantlessView(OperationRun|int $run): string
|
||||
{
|
||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
||||
|
||||
return route('admin.operations.view', ['run' => $runId]);
|
||||
}
|
||||
|
||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||
{
|
||||
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* DTO representing the access context for a workspace-scoped UI action.
|
||||
*/
|
||||
final readonly class WorkspaceAccessContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?User $user,
|
||||
public ?Workspace $workspace,
|
||||
public bool $isMember,
|
||||
public bool $hasCapability,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Non-members should receive 404 (deny-as-not-found).
|
||||
*/
|
||||
public function shouldDenyAsNotFound(): bool
|
||||
{
|
||||
return ! $this->isMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Members without capability should receive 403 (forbidden).
|
||||
*/
|
||||
public function shouldDenyAsForbidden(): bool
|
||||
{
|
||||
return $this->isMember && ! $this->hasCapability;
|
||||
}
|
||||
|
||||
/**
|
||||
* User is authorized to perform the action.
|
||||
*/
|
||||
public function isAuthorized(): bool
|
||||
{
|
||||
return $this->isMember && $this->hasCapability;
|
||||
}
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
|
||||
*
|
||||
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
|
||||
* + WorkspaceCapabilityResolver.
|
||||
*
|
||||
* Rules:
|
||||
* - Non-member → hidden UI + 404 server-side
|
||||
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||
* - Member with capability → enabled
|
||||
*/
|
||||
final class WorkspaceUiEnforcement
|
||||
{
|
||||
private Action $action;
|
||||
|
||||
private bool $requireMembership = true;
|
||||
|
||||
private ?string $capability = null;
|
||||
|
||||
private bool $isDestructive = false;
|
||||
|
||||
private ?string $customTooltip = null;
|
||||
|
||||
private Model|Closure|null $record = null;
|
||||
|
||||
private function __construct(Action $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a table action.
|
||||
*
|
||||
* @param Action $action The Filament action to wrap
|
||||
* @param Model|Closure $record The owner record or a closure that returns it
|
||||
*/
|
||||
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->record = $record;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function requireMembership(bool $require = true): self
|
||||
{
|
||||
$this->requireMembership = $require;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||
*/
|
||||
public function requireCapability(string $capability): self
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||
);
|
||||
}
|
||||
|
||||
$this->capability = $capability;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function destructive(): self
|
||||
{
|
||||
$this->isDestructive = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tooltip(string $message): self
|
||||
{
|
||||
$this->customTooltip = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function apply(): Action
|
||||
{
|
||||
$this->applyVisibility();
|
||||
$this->applyDisabledState();
|
||||
$this->applyDestructiveConfirmation();
|
||||
$this->applyServerSideGuard();
|
||||
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
private function applyVisibility(): void
|
||||
{
|
||||
if (! $this->requireMembership) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->visible(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
return $context->isMember;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDisabledState(): void
|
||||
{
|
||||
if ($this->capability === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$this->action->disabled(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if (! $context->isMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $context->hasCapability;
|
||||
});
|
||||
|
||||
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
return $tooltip;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDestructiveConfirmation(): void
|
||||
{
|
||||
if (! $this->isDestructive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->requiresConfirmation();
|
||||
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||
}
|
||||
|
||||
private function applyServerSideGuard(): void
|
||||
{
|
||||
$this->action->before(function (?Model $record = null): void {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->shouldDenyAsNotFound()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($context->shouldDenyAsForbidden()) {
|
||||
abort(403);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->resolveWorkspaceWithRecord($record);
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return new WorkspaceAccessContext(
|
||||
user: null,
|
||||
workspace: null,
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$isMember = $resolver->isMember($user, $workspace);
|
||||
|
||||
$hasCapability = true;
|
||||
if ($this->capability !== null && $isMember) {
|
||||
$hasCapability = $resolver->can($user, $workspace, $this->capability);
|
||||
}
|
||||
|
||||
return new WorkspaceAccessContext(
|
||||
user: $user,
|
||||
workspace: $workspace,
|
||||
isMember: $isMember,
|
||||
hasCapability: $hasCapability,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
|
||||
{
|
||||
if ($record instanceof Workspace) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
if ($this->record !== null) {
|
||||
try {
|
||||
$resolved = $this->record instanceof Closure
|
||||
? ($this->record)()
|
||||
: $this->record;
|
||||
|
||||
if ($resolved instanceof Workspace) {
|
||||
return $resolved;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class PreviousVerificationReportResolver
|
||||
{
|
||||
public static function resolvePreviousReportId(OperationRun $run): ?int
|
||||
{
|
||||
$runId = $run->getKey();
|
||||
|
||||
if (! is_int($runId) || $runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = self::providerConnectionId($run);
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('type', (string) $run->type)
|
||||
->where('run_identity_hash', (string) $run->run_identity_hash)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('id', '<', $runId)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($providerConnectionId !== null) {
|
||||
$query->where('context->provider_connection_id', $providerConnectionId);
|
||||
} else {
|
||||
$query->whereNull('context->provider_connection_id');
|
||||
}
|
||||
|
||||
$previousId = $query->value('id');
|
||||
|
||||
return is_int($previousId) ? $previousId : null;
|
||||
}
|
||||
|
||||
private static function providerConnectionId(OperationRun $run): ?int
|
||||
{
|
||||
$context = $run->context;
|
||||
|
||||
if (! is_array($context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||
|
||||
if (is_int($providerConnectionId)) {
|
||||
return $providerConnectionId;
|
||||
}
|
||||
|
||||
if (is_string($providerConnectionId) && ctype_digit(trim($providerConnectionId))) {
|
||||
return (int) trim($providerConnectionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,415 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
final class TenantPermissionCheckClusters
|
||||
{
|
||||
/**
|
||||
* @phpstan-type TenantPermissionRow array{
|
||||
* key:string,
|
||||
* type:'application'|'delegated',
|
||||
* description:?string,
|
||||
* features:array<int,string>,
|
||||
* status:'granted'|'missing'|'error',
|
||||
* details:array<string,mixed>|null
|
||||
* }
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $permissions
|
||||
* @param array{fresh?:bool,reason_code?:string,message?:string}|null $inventory
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function buildChecks(Tenant $tenant, array $permissions, ?array $inventory = null): array
|
||||
{
|
||||
$inventory = is_array($inventory) ? $inventory : [];
|
||||
|
||||
$inventoryFresh = $inventory['fresh'] ?? true;
|
||||
$inventoryFresh = is_bool($inventoryFresh) ? $inventoryFresh : true;
|
||||
|
||||
$inventoryReasonCode = $inventory['reason_code'] ?? null;
|
||||
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
|
||||
? $inventoryReasonCode
|
||||
: 'dependency_unreachable';
|
||||
|
||||
$inventoryMessage = $inventory['message'] ?? null;
|
||||
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
||||
? trim($inventoryMessage)
|
||||
: 'Unable to refresh observed permissions inventory during this run. Retry verification.';
|
||||
|
||||
$inventoryEvidence = self::inventoryEvidence($inventory);
|
||||
|
||||
/** @var array<int, TenantPermissionRow> $rows */
|
||||
$rows = collect($permissions)
|
||||
->filter(fn (mixed $row): bool => is_array($row))
|
||||
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$checks = [];
|
||||
|
||||
foreach (self::definitions() as $definition) {
|
||||
$key = (string) ($definition['key'] ?? 'unknown');
|
||||
$title = (string) ($definition['title'] ?? 'Check');
|
||||
|
||||
$clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matches($definition, $row)));
|
||||
|
||||
$checks[] = self::buildCheck(
|
||||
tenant: $tenant,
|
||||
key: $key,
|
||||
title: $title,
|
||||
clusterRows: $clusterRows,
|
||||
inventoryFresh: $inventoryFresh,
|
||||
inventoryReasonCode: $inventoryReasonCode,
|
||||
inventoryMessage: $inventoryMessage,
|
||||
inventoryEvidence: $inventoryEvidence,
|
||||
);
|
||||
}
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,title:string,mode:string,prefixes?:array<int,string>,keys?:array<int,string>}>
|
||||
*/
|
||||
private static function definitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'key' => 'permissions.admin_consent',
|
||||
'title' => 'Admin consent granted',
|
||||
'mode' => 'type',
|
||||
'type' => 'application',
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.directory_groups',
|
||||
'title' => 'Directory & group read access',
|
||||
'mode' => 'keys',
|
||||
'keys' => [
|
||||
'Directory.Read.All',
|
||||
'Group.Read.All',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_configuration',
|
||||
'title' => 'Intune configuration access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementConfiguration.',
|
||||
'DeviceManagementServiceConfig.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_apps',
|
||||
'title' => 'Intune apps access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementApps.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.intune_rbac_assignments',
|
||||
'title' => 'Intune RBAC & assignments prerequisites',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementRBAC.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions.scripts_remediations',
|
||||
'title' => 'Scripts/remediations access',
|
||||
'mode' => 'prefixes',
|
||||
'prefixes' => [
|
||||
'DeviceManagementScripts.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{mode:string,prefixes?:array<int,string>,keys?:array<int,string>,type?:string} $definition
|
||||
* @param TenantPermissionRow $row
|
||||
*/
|
||||
private static function matches(array $definition, array $row): bool
|
||||
{
|
||||
$mode = (string) ($definition['mode'] ?? '');
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
|
||||
if ($mode === 'type') {
|
||||
return ($row['type'] ?? null) === ($definition['type'] ?? null);
|
||||
}
|
||||
|
||||
if ($mode === 'keys') {
|
||||
$keys = $definition['keys'] ?? [];
|
||||
|
||||
return is_array($keys) && in_array($key, $keys, true);
|
||||
}
|
||||
|
||||
if ($mode === 'prefixes') {
|
||||
$prefixes = $definition['prefixes'] ?? [];
|
||||
|
||||
if (! is_array($prefixes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (is_string($prefix) && $prefix !== '' && str_starts_with($key, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $clusterRows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildCheck(
|
||||
Tenant $tenant,
|
||||
string $key,
|
||||
string $title,
|
||||
array $clusterRows,
|
||||
bool $inventoryFresh,
|
||||
string $inventoryReasonCode,
|
||||
string $inventoryMessage,
|
||||
array $inventoryEvidence,
|
||||
): array
|
||||
{
|
||||
if (! $inventoryFresh) {
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Warn->value,
|
||||
'severity' => VerificationCheckSeverity::Medium->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => $inventoryReasonCode,
|
||||
'message' => $inventoryMessage,
|
||||
'evidence' => $inventoryEvidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($clusterRows === []) {
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Skip->value,
|
||||
'severity' => VerificationCheckSeverity::Info->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'not_applicable',
|
||||
'message' => 'Not applicable for this tenant.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$missingApplication = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||
));
|
||||
|
||||
$missingDelegated = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||
));
|
||||
|
||||
$errored = array_values(array_filter(
|
||||
$clusterRows,
|
||||
static fn (array $row): bool => $row['status'] === 'error',
|
||||
));
|
||||
|
||||
$evidence = array_values(array_unique(array_merge(
|
||||
self::evidence($missingApplication, $missingDelegated, $errored),
|
||||
$inventoryEvidence,
|
||||
), SORT_REGULAR));
|
||||
|
||||
if ($missingApplication !== [] || $errored !== []) {
|
||||
$missingKeys = array_values(array_unique(array_merge(
|
||||
array_map(static fn (array $row): string => $row['key'], $missingApplication),
|
||||
array_map(static fn (array $row): string => $row['key'], $errored),
|
||||
)));
|
||||
|
||||
$message = $missingKeys !== []
|
||||
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
|
||||
: 'Missing required permissions.';
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Fail->value,
|
||||
'severity' => VerificationCheckSeverity::Critical->value,
|
||||
'blocking' => true,
|
||||
'reason_code' => 'ext.missing_permission',
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($missingDelegated !== []) {
|
||||
$missingKeys = array_values(array_unique(array_map(static fn (array $row): string => $row['key'], $missingDelegated)));
|
||||
$message = sprintf('Missing delegated permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)));
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Warn->value,
|
||||
'severity' => VerificationCheckSeverity::Medium->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ext.missing_delegated_permission',
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'next_steps' => [
|
||||
[
|
||||
'label' => 'Open required permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => VerificationCheckStatus::Pass->value,
|
||||
'severity' => VerificationCheckSeverity::Info->value,
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'All required permissions are granted.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantPermissionRow> $missingApplication
|
||||
* @param array<int, TenantPermissionRow> $missingDelegated
|
||||
* @param array<int, TenantPermissionRow> $errored
|
||||
* @return array<int, array{kind:string,value:int|string}>
|
||||
*/
|
||||
private static function evidence(array $missingApplication, array $missingDelegated, array $errored): array
|
||||
{
|
||||
$pointers = [];
|
||||
|
||||
foreach (array_merge($missingApplication, $missingDelegated, $errored) as $row) {
|
||||
$pointers[] = [
|
||||
'kind' => 'missing_permission',
|
||||
'value' => (string) ($row['key'] ?? ''),
|
||||
];
|
||||
|
||||
$pointers[] = [
|
||||
'kind' => 'permission_type',
|
||||
'value' => (string) ($row['type'] ?? 'application'),
|
||||
];
|
||||
|
||||
foreach (($row['features'] ?? []) as $feature) {
|
||||
if (! is_string($feature) || $feature === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pointers[] = [
|
||||
'kind' => 'feature',
|
||||
'value' => $feature,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$unique = [];
|
||||
|
||||
foreach ($pointers as $pointer) {
|
||||
$key = $pointer['kind'].':'.(string) $pointer['value'];
|
||||
$unique[$key] = $pointer;
|
||||
}
|
||||
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inventory
|
||||
* @return array<int, array{kind:string,value:int|string}>
|
||||
*/
|
||||
private static function inventoryEvidence(array $inventory): array
|
||||
{
|
||||
$pointers = [];
|
||||
|
||||
$appId = $inventory['app_id'] ?? null;
|
||||
if (is_string($appId) && $appId !== '') {
|
||||
$pointers[] = [
|
||||
'kind' => 'app_id',
|
||||
'value' => $appId,
|
||||
];
|
||||
}
|
||||
|
||||
$observedCount = $inventory['observed_permissions_count'] ?? null;
|
||||
if (is_int($observedCount) || (is_numeric($observedCount) && (string) (int) $observedCount === (string) $observedCount)) {
|
||||
$pointers[] = [
|
||||
'kind' => 'observed_permissions_count',
|
||||
'value' => (int) $observedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $pointers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return TenantPermissionRow
|
||||
*/
|
||||
private static function normalizePermissionRow(array $row): array
|
||||
{
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
$type = (string) ($row['type'] ?? 'application');
|
||||
$status = (string) ($row['status'] ?? 'missing');
|
||||
$description = $row['description'] ?? null;
|
||||
$features = $row['features'] ?? [];
|
||||
$details = $row['details'] ?? null;
|
||||
|
||||
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||
$type = 'application';
|
||||
}
|
||||
|
||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
||||
$status = 'missing';
|
||||
}
|
||||
|
||||
if (! is_string($description) || $description === '') {
|
||||
$description = null;
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||
|
||||
if (! is_array($details)) {
|
||||
$details = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'features' => $features,
|
||||
'status' => $status,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckSeverity: string
|
||||
{
|
||||
case Info = 'info';
|
||||
case Low = 'low';
|
||||
case Medium = 'medium';
|
||||
case High = 'high';
|
||||
case Critical = 'critical';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckStatus: string
|
||||
{
|
||||
case Pass = 'pass';
|
||||
case Fail = 'fail';
|
||||
case Warn = 'warn';
|
||||
case Skip = 'skip';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
*/
|
||||
public static function forChecks(array $checks): string
|
||||
{
|
||||
$tuples = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::normalizeKey($check['key'] ?? null);
|
||||
$status = self::normalizeEnumString($check['status'] ?? null);
|
||||
$reasonCode = self::normalizeEnumString($check['reason_code'] ?? null);
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
$severity = is_string($severity) ? trim($severity) : '';
|
||||
|
||||
if ($severity === '') {
|
||||
$severity = '';
|
||||
} else {
|
||||
$severity = strtolower($severity);
|
||||
}
|
||||
|
||||
$tuples[] = [
|
||||
'key' => $key,
|
||||
'tuple' => implode('|', [
|
||||
$key,
|
||||
$status,
|
||||
$blocking ? '1' : '0',
|
||||
$reasonCode,
|
||||
$severity,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
usort($tuples, static function (array $a, array $b): int {
|
||||
$keyComparison = $a['key'] <=> $b['key'];
|
||||
|
||||
if ($keyComparison !== 0) {
|
||||
return $keyComparison;
|
||||
}
|
||||
|
||||
return $a['tuple'] <=> $b['tuple'];
|
||||
});
|
||||
|
||||
$payload = implode("\n", array_map(static fn (array $item): string => (string) $item['tuple'], $tuples));
|
||||
|
||||
return hash('sha256', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function forReport(array $report): string
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> $checks */
|
||||
return self::forChecks($checks);
|
||||
}
|
||||
|
||||
private static function normalizeKey(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : $value;
|
||||
}
|
||||
|
||||
private static function normalizeEnumString(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : strtolower($value);
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationReportOverall: string
|
||||
{
|
||||
case Ready = 'ready';
|
||||
case NeedsAttention = 'needs_attention';
|
||||
case Blocked = 'blocked';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,421 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportSanitizer
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const FORBIDDEN_KEY_SUBSTRINGS = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'client_secret',
|
||||
'authorization',
|
||||
'password',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
];
|
||||
|
||||
/**
|
||||
* Evidence pointers must remain pointer-only. This allowlist is intentionally strict.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ALLOWED_EVIDENCE_KINDS = [
|
||||
'provider_connection_id',
|
||||
'entra_tenant_id',
|
||||
'organization_id',
|
||||
'http_status',
|
||||
'app_id',
|
||||
'observed_permissions_count',
|
||||
'missing_permission',
|
||||
'permission_type',
|
||||
'feature',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sanitizeReport(array $report): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
|
||||
if ($schemaVersion !== null) {
|
||||
$sanitized['schema_version'] = $schemaVersion;
|
||||
}
|
||||
|
||||
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
|
||||
if ($flow !== null) {
|
||||
$sanitized['flow'] = $flow;
|
||||
}
|
||||
|
||||
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
|
||||
if ($generatedAt !== null) {
|
||||
$sanitized['generated_at'] = $generatedAt;
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (is_string($fingerprint)) {
|
||||
$fingerprint = strtolower(trim($fingerprint));
|
||||
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
$sanitized['fingerprint'] = $fingerprint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId === null || is_int($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
} elseif (is_string($previousReportId)) {
|
||||
$previousReportId = trim($previousReportId);
|
||||
|
||||
if ($previousReportId === '') {
|
||||
$sanitized['previous_report_id'] = null;
|
||||
} elseif (ctype_digit($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = (int) $previousReportId;
|
||||
} else {
|
||||
$previousReportId = self::sanitizeShortString($previousReportId, fallback: null);
|
||||
|
||||
if ($previousReportId !== null) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($report['identity'] ?? null)) {
|
||||
$identity = self::sanitizeIdentity((array) $report['identity']);
|
||||
|
||||
if ($identity !== []) {
|
||||
$sanitized['identity'] = $identity;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
|
||||
$summary = self::sanitizeSummary($summary);
|
||||
|
||||
if ($summary !== null) {
|
||||
$sanitized['summary'] = $summary;
|
||||
}
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
|
||||
$checks = self::sanitizeChecks($checks);
|
||||
|
||||
if ($checks !== null) {
|
||||
$sanitized['checks'] = $checks;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private static function sanitizeIdentity(array $identity): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($identity as $key => $value) {
|
||||
if (! is_string($key) || trim($key) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[$key] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = self::sanitizeValueString($value);
|
||||
|
||||
if ($value !== null) {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
||||
*/
|
||||
private static function sanitizeSummary(array $summary): ?array
|
||||
{
|
||||
$overall = $summary['overall'] ?? null;
|
||||
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'overall' => $overall,
|
||||
'counts' => [
|
||||
'total' => $counts['total'],
|
||||
'pass' => $counts['pass'],
|
||||
'fail' => $counts['fail'],
|
||||
'warn' => $counts['warn'],
|
||||
'skip' => $counts['skip'],
|
||||
'running' => $counts['running'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $checks
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private static function sanitizeChecks(array $checks): ?array
|
||||
{
|
||||
if ($checks === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
|
||||
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
|
||||
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
|
||||
|
||||
if ($key === null || $title === null || $reasonCode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severityRaw = $check['severity'] ?? null;
|
||||
if (! is_string($severityRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severityRaw));
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messageRaw = $check['message'] ?? null;
|
||||
if (! is_string($messageRaw) || trim($messageRaw) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$sanitized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
'severity' => $severity,
|
||||
'blocking' => $blocking,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => self::sanitizeMessage($messageRaw),
|
||||
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
||||
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $evidence
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
*/
|
||||
private static function sanitizeEvidence(array $evidence): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
if (! is_string($kind) || trim($kind) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = trim($kind);
|
||||
|
||||
if (! in_array($kind, self::ALLOWED_EVIDENCE_KINDS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $value];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitizedValue = self::sanitizeValueString($value);
|
||||
|
||||
if ($sanitizedValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = ['kind' => $kind, 'value' => $sanitizedValue];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $nextSteps
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
private static function sanitizeNextSteps(array $nextSteps): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
||||
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private static function sanitizeMessage(mixed $message): string
|
||||
{
|
||||
if (! is_string($message)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
|
||||
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
|
||||
$message = str_ireplace(
|
||||
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
|
||||
'[REDACTED]',
|
||||
$message,
|
||||
);
|
||||
|
||||
$message = trim($message);
|
||||
|
||||
return $message === '' ? '—' : substr($message, 0, 240);
|
||||
}
|
||||
|
||||
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return substr($value, 0, 200);
|
||||
}
|
||||
|
||||
private static function sanitizeValueString(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($value) > 512) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lower = strtolower($value);
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function containsForbiddenKeySubstring(string $value): bool
|
||||
{
|
||||
$lower = strtolower($value);
|
||||
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,257 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class VerificationReportSchema
|
||||
{
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function normalizeReport(mixed $report): ?array
|
||||
{
|
||||
if (! is_array($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! self::isValidReport($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function isValidReport(array $report): bool
|
||||
{
|
||||
$schemaVersion = self::schemaVersion($report);
|
||||
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($report['flow'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
if (! is_array($summary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$overall = $summary['overall'] ?? null;
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
if (! is_array($counts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
if (! is_array($checks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check) || ! self::isValidCheckResult($check)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function schemaVersion(array $report): ?string
|
||||
{
|
||||
$candidate = $report['schema_version'] ?? null;
|
||||
|
||||
if (! is_string($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = trim($candidate);
|
||||
|
||||
if ($candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public static function isSupportedSchemaVersion(string $schemaVersion): bool
|
||||
{
|
||||
$parts = explode('.', $schemaVersion, 3);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$major = (int) $parts[0];
|
||||
|
||||
return $major === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
*/
|
||||
private static function isValidCheckResult(array $check): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($check['key'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['title'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = trim($severity);
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_bool($check['blocking'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['message'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$evidence = $check['evidence'] ?? null;
|
||||
if (! is_array($evidence)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? null;
|
||||
if (! is_array($nextSteps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step) || ! self::isValidNextStep($step)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pointer
|
||||
*/
|
||||
private static function isValidEvidencePointer(array $pointer): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
return is_int($value) || self::isNonEmptyString($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $step
|
||||
*/
|
||||
private static function isValidNextStep(array $step): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($step['label'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($step['url'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function isNonEmptyString(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && trim($value) !== '';
|
||||
}
|
||||
|
||||
private static function isNonNegativeInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value) && $value >= 0;
|
||||
}
|
||||
|
||||
private static function isIsoDateTimeString(mixed $value): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new DateTimeImmutable((string) $value);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user