feat: observe assignment fetch/restore runs
This commit is contained in:
parent
f45e0f5cf1
commit
1625d5139d
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -19,16 +19,25 @@ private function tableHasRecords(): bool
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make())
|
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
protected function getTableEmptyStateActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
$create,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,14 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
->authorize(fn (): bool => true),
|
->authorize(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $resolver->isMember($user, $tenant);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,7 +182,14 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
->authorize(fn (): bool => true);
|
->authorize(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $resolver->isMember($user, $tenant);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenantExternalIdForCreateAction(): ?string
|
private function resolveTenantExternalIdForCreateAction(): ?string
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -19,16 +19,25 @@ private function tableHasRecords(): bool
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make())
|
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
protected function getTableEmptyStateActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
$create,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Services\AssignmentBackupService;
|
use App\Services\AssignmentBackupService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -15,6 +17,8 @@ class FetchAssignmentsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of times the job may be attempted.
|
* The number of times the job may be attempted.
|
||||||
*/
|
*/
|
||||||
@ -32,8 +36,19 @@ public function __construct(
|
|||||||
public int $backupItemId,
|
public int $backupItemId,
|
||||||
public string $tenantExternalId,
|
public string $tenantExternalId,
|
||||||
public string $policyExternalId,
|
public string $policyExternalId,
|
||||||
public array $policyPayload
|
public array $policyPayload,
|
||||||
) {}
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Execute the job.
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\AssignmentRestoreService;
|
use App\Services\AssignmentRestoreService;
|
||||||
@ -16,6 +18,8 @@ class RestoreAssignmentsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public int $tries = 1;
|
public int $tries = 1;
|
||||||
|
|
||||||
public int $backoff = 0;
|
public int $backoff = 0;
|
||||||
@ -33,7 +37,18 @@ public function __construct(
|
|||||||
public array $foundationMapping = [],
|
public array $foundationMapping = [],
|
||||||
public ?string $actorEmail = null,
|
public ?string $actorEmail = null,
|
||||||
public ?string $actorName = null,
|
public ?string $actorName = null,
|
||||||
) {}
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Execute the job.
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class AssignmentBackupService
|
class AssignmentBackupService
|
||||||
@ -19,6 +21,7 @@ public function __construct(
|
|||||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||||
private readonly ScopeTagResolver $scopeTagResolver,
|
private readonly ScopeTagResolver $scopeTagResolver,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
|
private readonly OperationRunService $operationRunService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,6 +85,8 @@ public function enrichWithAssignments(
|
|||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
||||||
|
|
||||||
Log::warning('No assignments fetched for policy', [
|
Log::warning('No assignments fetched for policy', [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
@ -121,6 +126,8 @@ public function enrichWithAssignments(
|
|||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
||||||
|
|
||||||
Log::info('Assignments enriched for backup item', [
|
Log::info('Assignments enriched for backup item', [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
@ -132,6 +139,60 @@ public function enrichWithAssignments(
|
|||||||
return $backupItem->refresh();
|
return $backupItem->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $captureMetadata
|
||||||
|
*/
|
||||||
|
public function recordFetchOperationRun(BackupItem $backupItem, Tenant $tenant, array $captureMetadata = []): void
|
||||||
|
{
|
||||||
|
$run = $this->operationRunService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'assignments.fetch',
|
||||||
|
identityInputs: [
|
||||||
|
'backup_item_id' => (int) $backupItem->getKey(),
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'backup_set_id' => (int) $backupItem->backup_set_id,
|
||||||
|
'backup_item_id' => (int) $backupItem->getKey(),
|
||||||
|
'policy_id' => is_numeric($backupItem->policy_id) ? (int) $backupItem->policy_id : null,
|
||||||
|
'policy_identifier' => (string) $backupItem->policy_identifier,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($run->status === 'completed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRunService->updateRun($run, 'running');
|
||||||
|
|
||||||
|
$fetchFailed = (bool) ($captureMetadata['assignments_fetch_failed'] ?? false);
|
||||||
|
|
||||||
|
$reasonCandidate = $captureMetadata['assignments_fetch_error_code']
|
||||||
|
?? $captureMetadata['assignments_fetch_error']
|
||||||
|
?? ProviderReasonCodes::UnknownError;
|
||||||
|
|
||||||
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode(
|
||||||
|
$this->normalizeReasonCandidate($reasonCandidate)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRunService->updateRun(
|
||||||
|
$run,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $fetchFailed ? 'failed' : 'succeeded',
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => 1,
|
||||||
|
'processed' => $fetchFailed ? 0 : 1,
|
||||||
|
'failed' => $fetchFailed ? 1 : 0,
|
||||||
|
],
|
||||||
|
failures: $fetchFailed
|
||||||
|
? [[
|
||||||
|
'code' => 'assignments.fetch_failed',
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => (string) ($captureMetadata['assignments_fetch_error'] ?? 'Assignments fetch failed'),
|
||||||
|
]]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve scope tag IDs to display names.
|
* Resolve scope tag IDs to display names.
|
||||||
*/
|
*/
|
||||||
@ -233,4 +294,24 @@ private function extractAssignmentFilterIds(array $assignments): array
|
|||||||
|
|
||||||
return array_values(array_unique($filterIds));
|
return array_values(array_unique($filterIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeReasonCandidate(mixed $candidate): string
|
||||||
|
{
|
||||||
|
if (! is_string($candidate) && ! is_numeric($candidate)) {
|
||||||
|
return ProviderReasonCodes::UnknownError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim((string) $candidate);
|
||||||
|
|
||||||
|
if ($raw === '') {
|
||||||
|
return ProviderReasonCodes::UnknownError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = preg_replace('/(?<!^)[A-Z]/', '_$0', $raw) ?? $raw;
|
||||||
|
$raw = strtolower($raw);
|
||||||
|
$raw = str_replace([' ', '-', '.', '/'], '_', $raw);
|
||||||
|
$raw = preg_replace('/_+/', '_', $raw) ?? $raw;
|
||||||
|
|
||||||
|
return trim($raw, '_');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@ -20,7 +19,6 @@ public function __construct(
|
|||||||
private readonly GraphClientInterface $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly GraphLogger $graphLogger,
|
private readonly GraphLogger $graphLogger,
|
||||||
private readonly AuditLogger $auditLogger,
|
|
||||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
) {}
|
) {}
|
||||||
@ -141,20 +139,6 @@ public function restore(
|
|||||||
if ($assignmentFilterMapping === []) {
|
if ($assignmentFilterMapping === []) {
|
||||||
$outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.');
|
$outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.');
|
||||||
$summary['skipped']++;
|
$summary['skipped']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'skipped',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $assignment,
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'assignment_filter_id' => $filterId,
|
|
||||||
'reason' => 'Assignment filter mapping is unavailable.',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -169,20 +153,6 @@ public function restore(
|
|||||||
'Assignment filter mapping missing for filter ID.'
|
'Assignment filter mapping missing for filter ID.'
|
||||||
);
|
);
|
||||||
$summary['skipped']++;
|
$summary['skipped']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'skipped',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $assignment,
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'assignment_filter_id' => $filterId,
|
|
||||||
'reason' => 'Assignment filter mapping missing for filter ID.',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -201,20 +171,6 @@ public function restore(
|
|||||||
if ($mappedGroupId === 'SKIP') {
|
if ($mappedGroupId === 'SKIP') {
|
||||||
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
||||||
$summary['skipped']++;
|
$summary['skipped']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'skipped',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $assignment,
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'group_id' => $groupId,
|
|
||||||
'mapped_group_id' => $mappedGroupId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -262,20 +218,6 @@ public function restore(
|
|||||||
$meta['mapped_group_id']
|
$meta['mapped_group_id']
|
||||||
);
|
);
|
||||||
$summary['success']++;
|
$summary['success']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'created',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $meta['assignment'],
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'group_id' => $meta['group_id'],
|
|
||||||
'mapped_group_id' => $meta['mapped_group_id'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
|
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
|
||||||
@ -294,22 +236,6 @@ public function restore(
|
|||||||
$assignResponse
|
$assignResponse
|
||||||
);
|
);
|
||||||
$summary['failed']++;
|
$summary['failed']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'failed',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $meta['assignment'],
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'group_id' => $meta['group_id'],
|
|
||||||
'mapped_group_id' => $meta['mapped_group_id'],
|
|
||||||
'graph_error_message' => $assignResponse->meta['error_message'] ?? null,
|
|
||||||
'graph_error_code' => $assignResponse->meta['error_code'] ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,20 +323,6 @@ public function restore(
|
|||||||
if ($createResponse->successful()) {
|
if ($createResponse->successful()) {
|
||||||
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
|
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
|
||||||
$summary['success']++;
|
$summary['success']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'created',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $meta['assignment'],
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'group_id' => $meta['group_id'],
|
|
||||||
'mapped_group_id' => $meta['mapped_group_id'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
$outcomes[] = $this->failureOutcome(
|
$outcomes[] = $this->failureOutcome(
|
||||||
$meta['assignment'],
|
$meta['assignment'],
|
||||||
@ -420,22 +332,6 @@ public function restore(
|
|||||||
$createResponse
|
$createResponse
|
||||||
);
|
);
|
||||||
$summary['failed']++;
|
$summary['failed']++;
|
||||||
$this->logAssignmentOutcome(
|
|
||||||
status: 'failed',
|
|
||||||
tenant: $tenant,
|
|
||||||
assignment: $meta['assignment'],
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
metadata: [
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'group_id' => $meta['group_id'],
|
|
||||||
'mapped_group_id' => $meta['mapped_group_id'],
|
|
||||||
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
|
||||||
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usleep(100000);
|
usleep(100000);
|
||||||
@ -597,40 +493,4 @@ private function failureOutcome(
|
|||||||
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
|
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
|
||||||
], static fn ($value) => $value !== null);
|
], static fn ($value) => $value !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function logAssignmentOutcome(
|
|
||||||
string $status,
|
|
||||||
Tenant $tenant,
|
|
||||||
array $assignment,
|
|
||||||
?RestoreRun $restoreRun,
|
|
||||||
?string $actorEmail,
|
|
||||||
?string $actorName,
|
|
||||||
array $metadata
|
|
||||||
): void {
|
|
||||||
$action = match ($status) {
|
|
||||||
'created' => 'restore.assignment.created',
|
|
||||||
'failed' => 'restore.assignment.failed',
|
|
||||||
default => 'restore.assignment.skipped',
|
|
||||||
};
|
|
||||||
|
|
||||||
$statusLabel = match ($status) {
|
|
||||||
'created' => 'success',
|
|
||||||
'failed' => 'failed',
|
|
||||||
default => 'warning',
|
|
||||||
};
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: $action,
|
|
||||||
context: [
|
|
||||||
'metadata' => $metadata,
|
|
||||||
'assignment' => $this->sanitizeAssignment($assignment),
|
|
||||||
],
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
status: $statusLabel,
|
|
||||||
resourceType: 'restore_run',
|
|
||||||
resourceId: $restoreRun ? (string) $restoreRun->id : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,7 +93,7 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
|||||||
{
|
{
|
||||||
$cacheKey = "membership_{$user->id}_{$tenant->id}";
|
$cacheKey = "membership_{$user->id}_{$tenant->id}";
|
||||||
|
|
||||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||||
$membership = TenantMembership::query()
|
$membership = TenantMembership::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
@ -105,6 +105,47 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
|||||||
return $this->resolvedMemberships[$cacheKey];
|
return $this->resolvedMemberships[$cacheKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime membership cache for a set of tenants in one query.
|
||||||
|
*
|
||||||
|
* Used to avoid N+1 queries for bulk selection authorization.
|
||||||
|
*
|
||||||
|
* @param array<int, int|string> $tenantIds
|
||||||
|
*/
|
||||||
|
public function primeMemberships(User $user, array $tenantIds): void
|
||||||
|
{
|
||||||
|
$tenantIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $tenantIds)));
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingTenantIds = [];
|
||||||
|
foreach ($tenantIds as $tenantId) {
|
||||||
|
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||||
|
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||||
|
$missingTenantIds[] = $tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingTenantIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberships = TenantMembership::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereIn('tenant_id', $missingTenantIds)
|
||||||
|
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
||||||
|
|
||||||
|
$byTenantId = $memberships->keyBy('tenant_id');
|
||||||
|
|
||||||
|
foreach ($missingTenantIds as $tenantId) {
|
||||||
|
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||||
|
$membership = $byTenantId->get($tenantId);
|
||||||
|
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear cached memberships (useful for testing or after membership changes)
|
* Clear cached memberships (useful for testing or after membership changes)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
class AssignmentFetcher
|
class AssignmentFetcher
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MicrosoftGraphClient $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
class AssignmentFilterResolver
|
class AssignmentFilterResolver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MicrosoftGraphClient $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
class GroupResolver
|
class GroupResolver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MicrosoftGraphClient $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -290,9 +290,7 @@ private function snapshotPolicy(
|
|||||||
$captured = $captureResult['captured'];
|
$captured = $captureResult['captured'];
|
||||||
$payload = $captured['payload'];
|
$payload = $captured['payload'];
|
||||||
$metadata = $captured['metadata'] ?? [];
|
$metadata = $captured['metadata'] ?? [];
|
||||||
|
$backupItem = $this->createBackupItemFromVersion(
|
||||||
return [
|
|
||||||
$this->createBackupItemFromVersion(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
backupSet: $backupSet,
|
backupSet: $backupSet,
|
||||||
policy: $policy,
|
policy: $policy,
|
||||||
@ -302,9 +300,17 @@ private function snapshotPolicy(
|
|||||||
scopeTags: $captured['scope_tags'] ?? null,
|
scopeTags: $captured['scope_tags'] ?? null,
|
||||||
metadata: is_array($metadata) ? $metadata : [],
|
metadata: is_array($metadata) ? $metadata : [],
|
||||||
warnings: $captured['warnings'] ?? [],
|
warnings: $captured['warnings'] ?? [],
|
||||||
),
|
);
|
||||||
null,
|
|
||||||
];
|
if ($includeAssignments) {
|
||||||
|
$this->assignmentBackupService->recordFetchOperationRun(
|
||||||
|
backupItem: $backupItem,
|
||||||
|
tenant: $tenant,
|
||||||
|
captureMetadata: is_array($metadata) ? $metadata : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$backupItem, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\AssignmentFetcher;
|
use App\Services\Graph\AssignmentFetcher;
|
||||||
use App\Services\Graph\AssignmentFilterResolver;
|
use App\Services\Graph\AssignmentFilterResolver;
|
||||||
|
use App\Services\Graph\GraphException;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
@ -108,6 +109,9 @@ public function capture(
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$captureMetadata['assignments_fetch_failed'] = true;
|
$captureMetadata['assignments_fetch_failed'] = true;
|
||||||
$captureMetadata['assignments_fetch_error'] = $e->getMessage();
|
$captureMetadata['assignments_fetch_error'] = $e->getMessage();
|
||||||
|
$captureMetadata['assignments_fetch_error_code'] = $e instanceof GraphException
|
||||||
|
? ($e->status ?? null)
|
||||||
|
: (is_numeric($e->getCode()) ? (int) $e->getCode() : null);
|
||||||
|
|
||||||
Log::warning('Failed to fetch assignments during capture', [
|
Log::warning('Failed to fetch assignments during capture', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -295,6 +299,9 @@ public function ensureVersionHasAssignments(
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$metadata['assignments_fetch_failed'] = true;
|
$metadata['assignments_fetch_failed'] = true;
|
||||||
$metadata['assignments_fetch_error'] = $e->getMessage();
|
$metadata['assignments_fetch_error'] = $e->getMessage();
|
||||||
|
$metadata['assignments_fetch_error_code'] = $e instanceof GraphException
|
||||||
|
? ($e->status ?? null)
|
||||||
|
: (is_numeric($e->getCode()) ? (int) $e->getCode() : null);
|
||||||
|
|
||||||
Log::warning('Failed to backfill assignments for version', [
|
Log::warning('Failed to backfill assignments for version', [
|
||||||
'version_id' => $version->id,
|
'version_id' => $version->id,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -14,8 +15,10 @@
|
|||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\ProviderConnectionResolver;
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
use App\Services\Providers\ProviderGateway;
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -34,6 +37,7 @@ public function __construct(
|
|||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||||
|
private readonly OperationRunService $operationRunService,
|
||||||
private readonly FoundationMappingService $foundationMappingService,
|
private readonly FoundationMappingService $foundationMappingService,
|
||||||
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
private readonly ?ProviderGateway $providerGateway = null,
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
@ -378,6 +382,36 @@ public function execute(
|
|||||||
|
|
||||||
$results = $foundationEntries;
|
$results = $foundationEntries;
|
||||||
$hardFailures = $foundationFailures;
|
$hardFailures = $foundationFailures;
|
||||||
|
$assignmentRestoreRun = null;
|
||||||
|
$assignmentRestoreTotals = [
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
];
|
||||||
|
$assignmentRestoreFailures = [];
|
||||||
|
|
||||||
|
$assignmentRestoreItemCount = $policyItems
|
||||||
|
->filter(fn (BackupItem $policyItem): bool => is_array($policyItem->assignments) && $policyItem->assignments !== [])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if (! $dryRun && $assignmentRestoreItemCount > 0) {
|
||||||
|
$assignmentRestoreRun = $this->operationRunService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'assignments.restore',
|
||||||
|
identityInputs: [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'assignment_item_count' => (int) $assignmentRestoreItemCount,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($assignmentRestoreRun->status !== 'completed') {
|
||||||
|
$this->operationRunService->updateRun($assignmentRestoreRun, 'running');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($policyItems as $item) {
|
foreach ($policyItems as $item) {
|
||||||
$context = [
|
$context = [
|
||||||
@ -761,6 +795,41 @@ public function execute(
|
|||||||
|
|
||||||
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
|
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($assignmentSummary)) {
|
||||||
|
$assignmentRestoreTotals['success'] += (int) ($assignmentSummary['success'] ?? 0);
|
||||||
|
$assignmentRestoreTotals['failed'] += (int) ($assignmentSummary['failed'] ?? 0);
|
||||||
|
$assignmentRestoreTotals['skipped'] += (int) ($assignmentSummary['skipped'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($assignmentOutcomes)) {
|
||||||
|
foreach ($assignmentOutcomes['outcomes'] ?? [] as $assignmentOutcome) {
|
||||||
|
if (! is_array($assignmentOutcome)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($assignmentOutcome['status'] ?? null) !== 'failed') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = (string) ($assignmentOutcome['reason']
|
||||||
|
?? $assignmentOutcome['graph_error_message']
|
||||||
|
?? 'Assignment restore failed');
|
||||||
|
|
||||||
|
$reasonCandidate = $assignmentOutcome['graph_error_code']
|
||||||
|
?? $assignmentOutcome['reason']
|
||||||
|
?? $assignmentOutcome['graph_error_message']
|
||||||
|
?? ProviderReasonCodes::UnknownError;
|
||||||
|
|
||||||
|
$assignmentRestoreFailures[] = [
|
||||||
|
'code' => 'assignments.restore_failed',
|
||||||
|
'reason_code' => RunFailureSanitizer::normalizeReasonCode(
|
||||||
|
$this->normalizeFailureReasonCandidate($reasonCandidate)
|
||||||
|
),
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
|
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
|
||||||
$itemStatus = 'partial';
|
$itemStatus = 'partial';
|
||||||
$resultReason = 'Assignments restored with failures';
|
$resultReason = 'Assignments restored with failures';
|
||||||
@ -956,6 +1025,56 @@ public function execute(
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($assignmentRestoreRun instanceof OperationRun) {
|
||||||
|
$assignmentAttempted = $assignmentRestoreTotals['success'] + $assignmentRestoreTotals['failed'];
|
||||||
|
|
||||||
|
$assignmentRunOutcome = 'succeeded';
|
||||||
|
|
||||||
|
if ($assignmentRestoreTotals['failed'] > 0 && $assignmentRestoreTotals['success'] > 0) {
|
||||||
|
$assignmentRunOutcome = 'partially_succeeded';
|
||||||
|
} elseif ($assignmentRestoreTotals['failed'] > 0) {
|
||||||
|
$assignmentRunOutcome = 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRunService->updateRun(
|
||||||
|
$assignmentRestoreRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $assignmentRunOutcome,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $assignmentAttempted,
|
||||||
|
'processed' => $assignmentRestoreTotals['success'],
|
||||||
|
'failed' => $assignmentRestoreTotals['failed'],
|
||||||
|
],
|
||||||
|
failures: $assignmentRestoreFailures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$assignmentAuditStatus = match (true) {
|
||||||
|
$assignmentRestoreTotals['failed'] > 0 && $assignmentRestoreTotals['success'] === 0 => 'failed',
|
||||||
|
$assignmentRestoreTotals['failed'] > 0 || $assignmentRestoreTotals['skipped'] > 0 => 'partial',
|
||||||
|
default => 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.assignments.summary',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'total' => $assignmentAttempted,
|
||||||
|
'succeeded' => (int) $assignmentRestoreTotals['success'],
|
||||||
|
'failed' => (int) $assignmentRestoreTotals['failed'],
|
||||||
|
'skipped' => (int) $assignmentRestoreTotals['skipped'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->getKey(),
|
||||||
|
status: $assignmentAuditStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: $dryRun ? 'restore.previewed' : 'restore.executed',
|
action: $dryRun ? 'restore.previewed' : 'restore.executed',
|
||||||
@ -1025,6 +1144,26 @@ private function resolveRestoreMode(string $policyType): string
|
|||||||
return $restore;
|
return $restore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeFailureReasonCandidate(mixed $candidate): string
|
||||||
|
{
|
||||||
|
if (! is_string($candidate) && ! is_numeric($candidate)) {
|
||||||
|
return ProviderReasonCodes::UnknownError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim((string) $candidate);
|
||||||
|
|
||||||
|
if ($raw === '') {
|
||||||
|
return ProviderReasonCodes::UnknownError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = preg_replace('/(?<!^)[A-Z]/', '_$0', $raw) ?? $raw;
|
||||||
|
$raw = strtolower($raw);
|
||||||
|
$raw = str_replace([' ', '-', '.', '/'], '_', $raw);
|
||||||
|
$raw = preg_replace('/_+/', '_', $raw) ?? $raw;
|
||||||
|
|
||||||
|
return trim($raw, '_');
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveUpdateMethod(string $policyType): string
|
private function resolveUpdateMethod(string $policyType): string
|
||||||
{
|
{
|
||||||
$contract = $this->contracts->get($policyType);
|
$contract = $this->contracts->get($policyType);
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\AssignmentFetcher;
|
use App\Services\Graph\AssignmentFetcher;
|
||||||
use App\Services\Graph\AssignmentFilterResolver;
|
use App\Services\Graph\AssignmentFilterResolver;
|
||||||
|
use App\Services\Graph\GraphException;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
@ -182,6 +183,9 @@ public function captureFromGraph(
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$assignmentMetadata['assignments_fetch_failed'] = true;
|
$assignmentMetadata['assignments_fetch_failed'] = true;
|
||||||
$assignmentMetadata['assignments_fetch_error'] = $e->getMessage();
|
$assignmentMetadata['assignments_fetch_error'] = $e->getMessage();
|
||||||
|
$assignmentMetadata['assignments_fetch_error_code'] = $e instanceof GraphException
|
||||||
|
? ($e->status ?? null)
|
||||||
|
: (is_numeric($e->getCode()) ? (int) $e->getCode() : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,526 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Auth;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
class UiEnforcement
|
|
||||||
{
|
|
||||||
private const TENANT_RESOLVER_FILAMENT = 'filament';
|
|
||||||
|
|
||||||
private const TENANT_RESOLVER_RECORD = 'record';
|
|
||||||
|
|
||||||
private const TENANT_RESOLVER_CUSTOM = 'custom';
|
|
||||||
|
|
||||||
private const BULK_PREFLIGHT_CAPABILITY = 'capability';
|
|
||||||
|
|
||||||
private const BULK_PREFLIGHT_TENANT_MEMBERSHIP = 'tenant_membership';
|
|
||||||
|
|
||||||
private const BULK_PREFLIGHT_CUSTOM = 'custom';
|
|
||||||
|
|
||||||
private bool $preserveVisibility = false;
|
|
||||||
|
|
||||||
private ?\Closure $businessVisible = null;
|
|
||||||
|
|
||||||
private ?\Closure $businessHidden = null;
|
|
||||||
|
|
||||||
private string $tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
|
||||||
|
|
||||||
private ?\Closure $customTenantResolver = null;
|
|
||||||
|
|
||||||
private string $bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \Closure(Collection<int, Model>): bool|null
|
|
||||||
*/
|
|
||||||
private ?\Closure $bulkPreflight = null;
|
|
||||||
|
|
||||||
public function __construct(private string $capability)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function for(string $capability): self
|
|
||||||
{
|
|
||||||
return new self($capability);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function preserveVisibility(): self
|
|
||||||
{
|
|
||||||
if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
|
|
||||||
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->preserveVisibility = true;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function andVisibleWhen(callable $businessVisible): self
|
|
||||||
{
|
|
||||||
$this->businessVisible = \Closure::fromCallable($businessVisible);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function andHiddenWhen(callable $businessHidden): self
|
|
||||||
{
|
|
||||||
$this->businessHidden = \Closure::fromCallable($businessHidden);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantFromFilament(): self
|
|
||||||
{
|
|
||||||
$this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
|
||||||
$this->customTenantResolver = null;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantFromRecord(): self
|
|
||||||
{
|
|
||||||
if ($this->preserveVisibility) {
|
|
||||||
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tenantResolverMode = self::TENANT_RESOLVER_RECORD;
|
|
||||||
$this->customTenantResolver = null;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantFrom(callable $resolver): self
|
|
||||||
{
|
|
||||||
if ($this->preserveVisibility) {
|
|
||||||
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM;
|
|
||||||
$this->customTenantResolver = \Closure::fromCallable($resolver);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom bulk authorization preflight for selection.
|
|
||||||
*
|
|
||||||
* Signature: fn (Collection<int, Model> $records): bool
|
|
||||||
*/
|
|
||||||
public function preflightSelection(callable $preflight): self
|
|
||||||
{
|
|
||||||
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM;
|
|
||||||
$this->bulkPreflight = \Closure::fromCallable($preflight);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function preflightByTenantMembership(): self
|
|
||||||
{
|
|
||||||
$this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP;
|
|
||||||
$this->bulkPreflight = null;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function preflightByCapability(): self
|
|
||||||
{
|
|
||||||
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
|
||||||
$this->bulkPreflight = null;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function apply(Action $action): Action
|
|
||||||
{
|
|
||||||
$this->assertMixedVisibilityConfigIsValid();
|
|
||||||
|
|
||||||
if (! $this->preserveVisibility) {
|
|
||||||
$this->applyVisibility($action);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($action->isBulk()) {
|
|
||||||
$action->disabled(function () use ($action): bool {
|
|
||||||
/** @var Collection<int, Model> $records */
|
|
||||||
$records = collect($action->getSelectedRecords());
|
|
||||||
|
|
||||||
return $this->bulkIsDisabled($records);
|
|
||||||
});
|
|
||||||
|
|
||||||
$action->tooltip(function () use ($action): ?string {
|
|
||||||
/** @var Collection<int, Model> $records */
|
|
||||||
$records = collect($action->getSelectedRecords());
|
|
||||||
|
|
||||||
return $this->bulkDisabledTooltip($records);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record));
|
|
||||||
$action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAllowed(?Model $record = null): bool
|
|
||||||
{
|
|
||||||
return ! $this->isDisabled($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function authorizeOrAbort(?Model $record = null): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
|
|
||||||
$tenant = $this->resolveTenant($record);
|
|
||||||
|
|
||||||
if (! ($tenant instanceof Tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless($this->isMemberOfTenant($user, $tenant), 404);
|
|
||||||
abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side enforcement for bulk selections.
|
|
||||||
*
|
|
||||||
* - If any selected tenant is not a membership: 404 (deny-as-not-found).
|
|
||||||
* - If all are memberships but any lacks capability: 403.
|
|
||||||
*
|
|
||||||
* @param Collection<int, Model> $records
|
|
||||||
*/
|
|
||||||
public function authorizeBulkSelectionOrAbort(Collection $records): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
|
|
||||||
$tenantIds = $this->resolveTenantIdsForRecords($records);
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$membershipTenantIds = $this->membershipTenantIds($user, $tenantIds);
|
|
||||||
|
|
||||||
if (count($membershipTenantIds) !== count($tenantIds)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds);
|
|
||||||
|
|
||||||
if (count($allowedTenantIds) !== count($tenantIds)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public helper for evaluating bulk selection authorization decisions.
|
|
||||||
*
|
|
||||||
* @param Collection<int, Model> $records
|
|
||||||
*/
|
|
||||||
public function bulkSelectionIsAuthorized(User $user, Collection $records): bool
|
|
||||||
{
|
|
||||||
return $this->bulkSelectionIsAuthorizedInternal($user, $records);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyVisibility(Action $action): void
|
|
||||||
{
|
|
||||||
$canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT);
|
|
||||||
|
|
||||||
$businessVisible = $this->businessVisible;
|
|
||||||
$businessHidden = $this->businessHidden;
|
|
||||||
|
|
||||||
if ($businessVisible instanceof \Closure) {
|
|
||||||
$action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool {
|
|
||||||
if (! (bool) $action->evaluate($businessVisible)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $canApplyMemberVisibility) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $action->getRecord();
|
|
||||||
|
|
||||||
return $this->isMember($record instanceof Model ? $record : null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($businessHidden instanceof \Closure) {
|
|
||||||
$action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool {
|
|
||||||
if ($canApplyMemberVisibility) {
|
|
||||||
$record = $action->getRecord();
|
|
||||||
|
|
||||||
if (! $this->isMember($record instanceof Model ? $record : null)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bool) $action->evaluate($businessHidden);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $canApplyMemberVisibility) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! ($businessVisible instanceof \Closure)) {
|
|
||||||
$action->hidden(function () use ($action): bool {
|
|
||||||
$record = $action->getRecord();
|
|
||||||
|
|
||||||
return ! $this->isMember($record instanceof Model ? $record : null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertMixedVisibilityConfigIsValid(): void
|
|
||||||
{
|
|
||||||
if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) {
|
|
||||||
throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
|
|
||||||
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDisabled(?Model $record = null): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! ($user instanceof User)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $this->resolveTenant($record);
|
|
||||||
|
|
||||||
if (! ($tenant instanceof Tenant)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isMemberOfTenant($user, $tenant)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows($this->capability, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function disabledTooltip(?Model $record = null): ?string
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! ($user instanceof User)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $this->resolveTenant($record);
|
|
||||||
|
|
||||||
if (! ($tenant instanceof Tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isMemberOfTenant($user, $tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Gate::forUser($user)->allows($this->capability, $tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return UiTooltips::insufficientPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bulkIsDisabled(Collection $records): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! ($user instanceof User)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $this->bulkSelectionIsAuthorizedInternal($user, $records);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bulkDisabledTooltip(Collection $records): ?string
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! ($user instanceof User)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return UiTooltips::insufficientPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool
|
|
||||||
{
|
|
||||||
if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) {
|
|
||||||
return (bool) ($this->bulkPreflight)($records);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = $this->resolveTenantIdsForRecords($records);
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($this->bulkPreflightMode) {
|
|
||||||
self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds),
|
|
||||||
self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds),
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Model> $records
|
|
||||||
* @return array<int>
|
|
||||||
*/
|
|
||||||
private function resolveTenantIdsForRecords(Collection $records): array
|
|
||||||
{
|
|
||||||
if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) {
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) {
|
|
||||||
$ids = $records
|
|
||||||
->filter(fn (Model $record): bool => $record instanceof Tenant)
|
|
||||||
->map(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return array_values(array_unique($ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) {
|
|
||||||
$ids = [];
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
if (! ($record instanceof Model)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = ($this->customTenantResolver)($record);
|
|
||||||
|
|
||||||
if ($resolved instanceof Tenant) {
|
|
||||||
$ids[] = (int) $resolved->getKey();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_int($resolved)) {
|
|
||||||
$ids[] = $resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isMember(?Model $record = null): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! ($user instanceof User)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $this->resolveTenant($record);
|
|
||||||
|
|
||||||
if (! ($tenant instanceof Tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->isMemberOfTenant($user, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isMemberOfTenant(User $user, Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveTenant(?Model $record = null): ?Tenant
|
|
||||||
{
|
|
||||||
return match ($this->tenantResolverMode) {
|
|
||||||
self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
|
||||||
self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null,
|
|
||||||
self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveTenantViaCustomResolver(?Model $record): ?Tenant
|
|
||||||
{
|
|
||||||
if (! ($this->customTenantResolver instanceof \Closure)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! ($record instanceof Model)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = ($this->customTenantResolver)($record);
|
|
||||||
|
|
||||||
if ($resolved instanceof Tenant) {
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int> $tenantIds
|
|
||||||
* @return array<int>
|
|
||||||
*/
|
|
||||||
private function membershipTenantIds(User $user, array $tenantIds): array
|
|
||||||
{
|
|
||||||
/** @var array<int> $ids */
|
|
||||||
$ids = DB::table('tenant_memberships')
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->whereIn('tenant_id', $tenantIds)
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->map(fn ($id): int => (int) $id)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return array_values(array_unique($ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int> $tenantIds
|
|
||||||
* @return array<int>
|
|
||||||
*/
|
|
||||||
private function capabilityTenantIds(User $user, array $tenantIds): array
|
|
||||||
{
|
|
||||||
$roles = RoleCapabilityMap::rolesWithCapability($this->capability);
|
|
||||||
|
|
||||||
if ($roles === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<int> $ids */
|
|
||||||
$ids = DB::table('tenant_memberships')
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->whereIn('tenant_id', $tenantIds)
|
|
||||||
->whereIn('role', $roles)
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->map(fn ($id): int => (int) $id)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return array_values(array_unique($ids));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,6 +32,8 @@ public static function labels(): array
|
|||||||
'backup_schedule_retention' => 'Backup schedule retention',
|
'backup_schedule_retention' => 'Backup schedule retention',
|
||||||
'backup_schedule_purge' => 'Backup schedule purge',
|
'backup_schedule_purge' => 'Backup schedule purge',
|
||||||
'restore.execute' => 'Restore execution',
|
'restore.execute' => 'Restore execution',
|
||||||
|
'assignments.fetch' => 'Assignment fetch',
|
||||||
|
'assignments.restore' => 'Assignment restore',
|
||||||
'directory_role_definitions.sync' => 'Role definitions sync',
|
'directory_role_definitions.sync' => 'Role definitions sync',
|
||||||
'restore_run.delete' => 'Delete restore runs',
|
'restore_run.delete' => 'Delete restore runs',
|
||||||
'restore_run.restore' => 'Restore restore runs',
|
'restore_run.restore' => 'Restore restore runs',
|
||||||
@ -64,6 +66,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
|||||||
'compliance.snapshot' => 180,
|
'compliance.snapshot' => 180,
|
||||||
'entra_group_sync' => 120,
|
'entra_group_sync' => 120,
|
||||||
'drift_generate_findings' => 240,
|
'drift_generate_findings' => 240,
|
||||||
|
'assignments.fetch', 'assignments.restore' => 60,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,6 +188,39 @@ public function apply(): Action|BulkAction
|
|||||||
return $this->action;
|
return $this->action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate whether a bulk selection is authorized (all-or-nothing).
|
||||||
|
*
|
||||||
|
* - If any selected tenant is not a membership: false.
|
||||||
|
* - If all are memberships but any lacks capability: false.
|
||||||
|
*/
|
||||||
|
public function bulkSelectionIsAuthorized(User $user, Collection $records): bool
|
||||||
|
{
|
||||||
|
$tenantIds = $this->resolveTenantIdsFromRecords($records);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$resolver->primeMemberships($user, $tenantIds);
|
||||||
|
|
||||||
|
foreach ($tenantIds as $tenantId) {
|
||||||
|
$tenant = $this->makeTenantStub($tenantId);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->capability !== null && ! $resolver->can($user, $tenant, $this->capability)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide action for non-members.
|
* Hide action for non-members.
|
||||||
*
|
*
|
||||||
@ -286,6 +319,19 @@ private function applyDisabledState(): void
|
|||||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$this->action->disabled(function (?Model $record = null) {
|
$this->action->disabled(function (?Model $record = null) {
|
||||||
|
if ($this->isBulk && $this->action instanceof BulkAction) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, Model> $records */
|
||||||
|
$records = collect($this->action->getSelectedRecords());
|
||||||
|
|
||||||
|
return ! $this->bulkSelectionIsAuthorized($user, $records);
|
||||||
|
}
|
||||||
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
// Non-members are hidden, so this only affects members
|
// Non-members are hidden, so this only affects members
|
||||||
@ -298,6 +344,23 @@ private function applyDisabledState(): void
|
|||||||
|
|
||||||
// Only show tooltip when actually disabled
|
// Only show tooltip when actually disabled
|
||||||
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
||||||
|
if ($this->isBulk && $this->action instanceof BulkAction) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, Model> $records */
|
||||||
|
$records = collect($this->action->getSelectedRecords());
|
||||||
|
|
||||||
|
if (! $this->bulkSelectionIsAuthorized($user, $records)) {
|
||||||
|
return $tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
if ($context->isMember && ! $context->hasCapability) {
|
if ($context->isMember && ! $context->hasCapability) {
|
||||||
@ -332,6 +395,21 @@ private function applyDestructiveConfirmation(): void
|
|||||||
private function applyServerSideGuard(): void
|
private function applyServerSideGuard(): void
|
||||||
{
|
{
|
||||||
$this->action->before(function (?Model $record = null): void {
|
$this->action->before(function (?Model $record = null): void {
|
||||||
|
if ($this->isBulk && $this->action instanceof BulkAction) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, Model> $records */
|
||||||
|
$records = collect($this->action->getSelectedRecords());
|
||||||
|
|
||||||
|
$this->authorizeBulkSelectionOrAbort($user, $records);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
// Non-member → 404 (deny-as-not-found)
|
// Non-member → 404 (deny-as-not-found)
|
||||||
@ -346,6 +424,99 @@ private function applyServerSideGuard(): void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side enforcement for bulk selections.
|
||||||
|
*
|
||||||
|
* - If any selected tenant is not a membership: 404 (deny-as-not-found).
|
||||||
|
* - If all are memberships but any lacks capability: 403.
|
||||||
|
*/
|
||||||
|
private function authorizeBulkSelectionOrAbort(User $user, Collection $records): void
|
||||||
|
{
|
||||||
|
$tenantIds = $this->resolveTenantIdsFromRecords($records);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$resolver->primeMemberships($user, $tenantIds);
|
||||||
|
|
||||||
|
foreach ($tenantIds as $tenantId) {
|
||||||
|
$tenant = $this->makeTenantStub($tenantId);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->capability === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tenantIds as $tenantId) {
|
||||||
|
$tenant = $this->makeTenantStub($tenantId);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, $this->capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Model> $records
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIdsFromRecords(Collection $records): array
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ($record instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $record->getKey();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof Model) {
|
||||||
|
$tenantId = $record->getAttribute('tenant_id');
|
||||||
|
if ($tenantId !== null) {
|
||||||
|
$tenantIds[] = (int) $tenantId;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
|
||||||
|
$relatedTenant = $record->getRelation('tenant');
|
||||||
|
|
||||||
|
if ($relatedTenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $relatedTenant->getKey();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeTenantStub(int $tenantId): Tenant
|
||||||
|
{
|
||||||
|
$tenant = new Tenant;
|
||||||
|
$tenant->forceFill(['id' => $tenantId]);
|
||||||
|
$tenant->exists = true;
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the current access context with an optional record.
|
* Resolve the current access context with an optional record.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
return $workspace;
|
return $workspace;
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'ensure-workspace-member'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
|
||||||
->prefix('/admin/w/{workspace}')
|
->prefix('/admin/w/{workspace}')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
|
Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user