feat: observe assignment fetch/restore runs

This commit is contained in:
Ahmed Darrazi 2026-02-15 13:39:51 +01:00
parent f45e0f5cf1
commit 1625d5139d
19 changed files with 545 additions and 697 deletions

View File

@ -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,
];
}
}

View File

@ -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

View File

@ -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,
];
}
}

View File

@ -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.

View File

@ -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.

View File

@ -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, '_');
}
}

View File

@ -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
);
}
}

View File

@ -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)
*/

View File

@ -7,7 +7,7 @@
class AssignmentFetcher
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
) {}

View File

@ -9,7 +9,7 @@
class AssignmentFilterResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphClientInterface $graphClient,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
) {}

View File

@ -8,7 +8,7 @@
class GroupResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphClientInterface $graphClient,
) {}
/**

View File

@ -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];
}
/**

View File

@ -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,

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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,
};
}

View File

@ -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.
*/

View File

@ -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')]))