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\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -19,16 +19,25 @@ private function tableHasRecords(): bool
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make())
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
||||
$create,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,14 @@ protected function getHeaderActions(): array
|
||||
|
||||
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;
|
||||
})
|
||||
->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
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiEnforcement;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -19,16 +19,25 @@ private function tableHasRecords(): bool
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make())
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
||||
$create,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\AssignmentBackupService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -15,6 +17,8 @@ class FetchAssignmentsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
@ -32,8 +36,19 @@ public function __construct(
|
||||
public int $backupItemId,
|
||||
public string $tenantExternalId,
|
||||
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.
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
@ -16,6 +18,8 @@ class RestoreAssignmentsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $backoff = 0;
|
||||
@ -33,7 +37,18 @@ public function __construct(
|
||||
public array $foundationMapping = [],
|
||||
public ?string $actorEmail = 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.
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AssignmentBackupService
|
||||
@ -19,6 +21,7 @@ public function __construct(
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
private readonly ScopeTagResolver $scopeTagResolver,
|
||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -82,6 +85,8 @@ public function enrichWithAssignments(
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
||||
|
||||
Log::warning('No assignments fetched for policy', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_id' => $policyId,
|
||||
@ -121,6 +126,8 @@ public function enrichWithAssignments(
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
||||
|
||||
Log::info('Assignments enriched for backup item', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_id' => $policyId,
|
||||
@ -132,6 +139,60 @@ public function enrichWithAssignments(
|
||||
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.
|
||||
*/
|
||||
@ -233,4 +294,24 @@ private function extractAssignmentFilterIds(array $assignments): array
|
||||
|
||||
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\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@ -20,7 +19,6 @@ public function __construct(
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly GraphLogger $graphLogger,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||
) {}
|
||||
@ -141,20 +139,6 @@ public function restore(
|
||||
if ($assignmentFilterMapping === []) {
|
||||
$outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.');
|
||||
$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;
|
||||
}
|
||||
@ -169,20 +153,6 @@ public function restore(
|
||||
'Assignment filter mapping missing for filter ID.'
|
||||
);
|
||||
$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;
|
||||
}
|
||||
@ -201,20 +171,6 @@ public function restore(
|
||||
if ($mappedGroupId === 'SKIP') {
|
||||
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
||||
$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;
|
||||
}
|
||||
@ -262,20 +218,6 @@ public function restore(
|
||||
$meta['mapped_group_id']
|
||||
);
|
||||
$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 {
|
||||
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
|
||||
@ -294,22 +236,6 @@ public function restore(
|
||||
$assignResponse
|
||||
);
|
||||
$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()) {
|
||||
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
|
||||
$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 {
|
||||
$outcomes[] = $this->failureOutcome(
|
||||
$meta['assignment'],
|
||||
@ -420,22 +332,6 @@ public function restore(
|
||||
$createResponse
|
||||
);
|
||||
$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);
|
||||
@ -597,40 +493,4 @@ private function failureOutcome(
|
||||
'graph_client_request_id' => $response?->meta['client_request_id'] ?? 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}";
|
||||
|
||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||
$membership = TenantMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
@ -105,6 +105,47 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
||||
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)
|
||||
*/
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
class AssignmentFetcher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MicrosoftGraphClient $graphClient,
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
) {}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
class AssignmentFilterResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MicrosoftGraphClient $graphClient,
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||
) {}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
class GroupResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MicrosoftGraphClient $graphClient,
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
||||
@ -290,21 +290,27 @@ private function snapshotPolicy(
|
||||
$captured = $captureResult['captured'];
|
||||
$payload = $captured['payload'];
|
||||
$metadata = $captured['metadata'] ?? [];
|
||||
$backupItem = $this->createBackupItemFromVersion(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
policy: $policy,
|
||||
version: $version,
|
||||
payload: is_array($payload) ? $payload : [],
|
||||
assignments: $captured['assignments'] ?? null,
|
||||
scopeTags: $captured['scope_tags'] ?? null,
|
||||
metadata: is_array($metadata) ? $metadata : [],
|
||||
warnings: $captured['warnings'] ?? [],
|
||||
);
|
||||
|
||||
return [
|
||||
$this->createBackupItemFromVersion(
|
||||
if ($includeAssignments) {
|
||||
$this->assignmentBackupService->recordFetchOperationRun(
|
||||
backupItem: $backupItem,
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
policy: $policy,
|
||||
version: $version,
|
||||
payload: is_array($payload) ? $payload : [],
|
||||
assignments: $captured['assignments'] ?? null,
|
||||
scopeTags: $captured['scope_tags'] ?? null,
|
||||
metadata: is_array($metadata) ? $metadata : [],
|
||||
warnings: $captured['warnings'] ?? [],
|
||||
),
|
||||
null,
|
||||
];
|
||||
captureMetadata: is_array($metadata) ? $metadata : [],
|
||||
);
|
||||
}
|
||||
|
||||
return [$backupItem, null];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
@ -108,6 +109,9 @@ public function capture(
|
||||
} catch (\Throwable $e) {
|
||||
$captureMetadata['assignments_fetch_failed'] = true;
|
||||
$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', [
|
||||
'tenant_id' => $tenant->id,
|
||||
@ -295,6 +299,9 @@ public function ensureVersionHasAssignments(
|
||||
} catch (\Throwable $e) {
|
||||
$metadata['assignments_fetch_failed'] = true;
|
||||
$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', [
|
||||
'version_id' => $version->id,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -14,8 +15,10 @@
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -34,6 +37,7 @@ public function __construct(
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly FoundationMappingService $foundationMappingService,
|
||||
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||
private readonly ?ProviderGateway $providerGateway = null,
|
||||
@ -378,6 +382,36 @@ public function execute(
|
||||
|
||||
$results = $foundationEntries;
|
||||
$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) {
|
||||
$context = [
|
||||
@ -761,6 +795,41 @@ public function execute(
|
||||
|
||||
$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') {
|
||||
$itemStatus = 'partial';
|
||||
$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(
|
||||
tenant: $tenant,
|
||||
action: $dryRun ? 'restore.previewed' : 'restore.executed',
|
||||
@ -1025,6 +1144,26 @@ private function resolveRestoreMode(string $policyType): string
|
||||
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
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
@ -182,6 +183,9 @@ public function captureFromGraph(
|
||||
} catch (\Throwable $e) {
|
||||
$assignmentMetadata['assignments_fetch_failed'] = true;
|
||||
$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_purge' => 'Backup schedule purge',
|
||||
'restore.execute' => 'Restore execution',
|
||||
'assignments.fetch' => 'Assignment fetch',
|
||||
'assignments.restore' => 'Assignment restore',
|
||||
'directory_role_definitions.sync' => 'Role definitions sync',
|
||||
'restore_run.delete' => 'Delete restore runs',
|
||||
'restore_run.restore' => 'Restore restore runs',
|
||||
@ -64,6 +66,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'compliance.snapshot' => 180,
|
||||
'entra_group_sync' => 120,
|
||||
'drift_generate_findings' => 240,
|
||||
'assignments.fetch', 'assignments.restore' => 60,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -188,6 +188,39 @@ public function apply(): Action|BulkAction
|
||||
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.
|
||||
*
|
||||
@ -286,6 +319,19 @@ private function applyDisabledState(): void
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$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);
|
||||
|
||||
// Non-members are hidden, so this only affects members
|
||||
@ -298,6 +344,23 @@ private function applyDisabledState(): void
|
||||
|
||||
// Only show tooltip when actually disabled
|
||||
$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);
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
@ -332,6 +395,21 @@ private function applyDestructiveConfirmation(): void
|
||||
private function applyServerSideGuard(): 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);
|
||||
|
||||
// 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.
|
||||
*/
|
||||
|
||||
@ -117,7 +117,7 @@
|
||||
return $workspace;
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-member'])
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
|
||||
->prefix('/admin/w/{workspace}')
|
||||
->group(function (): void {
|
||||
Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user