Compare commits
10 Commits
dev
...
251-commer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606e9760dd | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -264,8 +264,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
|
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
|
||||||
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
|
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
|
||||||
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
||||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
|
||||||
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -300,9 +298,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
|
||||||
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
||||||
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
||||||
|
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
@ -6,14 +6,12 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class PurgeLegacyBaselineGapRuns extends Command
|
class PurgeLegacyBaselineGapRuns extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
{--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)}
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||||
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||||
{--workspace=* : Limit cleanup to workspace ids}
|
{--workspace=* : Limit cleanup to workspace ids}
|
||||||
{--limit=500 : Maximum candidate runs to inspect}
|
{--limit=500 : Maximum candidate runs to inspect}
|
||||||
@ -101,35 +99,21 @@ public function handle(): int
|
|||||||
*/
|
*/
|
||||||
private function normalizedTypes(): array
|
private function normalizedTypes(): array
|
||||||
{
|
{
|
||||||
$requestedTypes = array_values(array_unique(array_filter(
|
$types = array_values(array_unique(array_filter(
|
||||||
array_map(
|
array_map(
|
||||||
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||||
(array) $this->option('type'),
|
(array) $this->option('type'),
|
||||||
),
|
),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
$canonicalTypes = array_values(array_unique(array_filter(array_map(
|
if ($types === []) {
|
||||||
static fn (string $type): ?string => match ($type) {
|
return ['baseline_compare', 'baseline_capture'];
|
||||||
OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value,
|
|
||||||
OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value,
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
$requestedTypes,
|
|
||||||
))));
|
|
||||||
|
|
||||||
if ($canonicalTypes === []) {
|
|
||||||
$canonicalTypes = [
|
|
||||||
OperationRunType::BaselineCompare->value,
|
|
||||||
OperationRunType::BaselineCapture->value,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique(array_merge(
|
return array_values(array_filter(
|
||||||
...array_map(
|
$types,
|
||||||
static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type),
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||||
$canonicalTypes,
|
));
|
||||||
),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class TenantpilotBackfillFindingLifecycle extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||||
|
{--tenant=* : Limit to tenant_id/external_id}';
|
||||||
|
|
||||||
|
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||||
|
|
||||||
|
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||||
|
{
|
||||||
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
|
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||||
|
|
||||||
|
if ($tenants->isEmpty()) {
|
||||||
|
$this->info('No tenants matched the provided identifiers.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queued = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$nothingToDo = 0;
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $runbookService->start(
|
||||||
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
|
initiator: null,
|
||||||
|
reason: null,
|
||||||
|
source: 'cli',
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $e) {
|
||||||
|
$this->error(sprintf(
|
||||||
|
'Backfill paused for tenant %d: %s',
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$e->getMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
$errors = $e->errors();
|
||||||
|
|
||||||
|
if (isset($errors['preflight.affected_count'])) {
|
||||||
|
$nothingToDo++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error(sprintf(
|
||||||
|
'Backfill blocked for tenant %d: %s',
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$e->getMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
|
||||||
|
$queued,
|
||||||
|
$skipped,
|
||||||
|
$nothingToDo,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||||
|
*/
|
||||||
|
private function resolveTenants(array $tenantIdentifiers)
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant($identifier)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = array_values(array_unique($tenantIds));
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Services\Runbooks\RunbookReason;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class TenantpilotRunDeployRunbooks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||||
|
|
||||||
|
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||||
|
|
||||||
|
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$runbookService->start(
|
||||||
|
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||||
|
initiator: null,
|
||||||
|
reason: new RunbookReason(
|
||||||
|
reasonCode: RunbookReason::CODE_DATA_REPAIR,
|
||||||
|
reasonText: 'Deploy hook automated runbooks',
|
||||||
|
),
|
||||||
|
source: 'deploy_hook',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('Deploy runbooks started (if needed).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (OperationalControlBlockedException $e) {
|
||||||
|
$this->info('Deploy runbooks paused: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
$errors = $e->errors();
|
||||||
|
|
||||||
|
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
|
||||||
|
|
||||||
|
if ($skippable) {
|
||||||
|
$this->info('Deploy runbooks skipped (nothing to do or already running).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Deploy runbooks blocked by validation errors.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,9 +9,4 @@
|
|||||||
class Login extends BaseLogin
|
class Login extends BaseLogin
|
||||||
{
|
{
|
||||||
protected string $view = 'filament.pages.auth.login';
|
protected string $view = 'filament.pages.auth.login';
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return __('localization.auth.sign_in_microsoft');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,11 +16,6 @@
|
|||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
||||||
@ -62,31 +57,6 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.reporting');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.customer_reviews');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.customer_review_workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function tenantPrefilterUrl(Tenant $tenant): string
|
public static function tenantPrefilterUrl(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
$tenantIdentifier = filled($tenant->external_id)
|
$tenantIdentifier = filled($tenant->external_id)
|
||||||
@ -114,7 +84,7 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('clear_filters')
|
Action::make('clear_filters')
|
||||||
->label(__('localization.review.clear_filters'))
|
->label('Clear filters')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
@ -135,9 +105,9 @@ public function table(Table $table): Table
|
|||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
|
||||||
TextColumn::make('latest_review')
|
TextColumn::make('latest_review')
|
||||||
->label(__('localization.review.latest_review'))
|
->label('Latest review')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||||
@ -146,25 +116,25 @@ public function table(Table $table): Table
|
|||||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('finding_summary')
|
TextColumn::make('finding_summary')
|
||||||
->label(__('localization.review.key_findings'))
|
->label('Key findings')
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('accepted_risk_summary')
|
TextColumn::make('accepted_risk_summary')
|
||||||
->label(__('localization.review.accepted_risks'))
|
->label('Accepted risks')
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('published_at')
|
TextColumn::make('published_at')
|
||||||
->label(__('localization.review.published'))
|
->label('Published')
|
||||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('review_pack_state')
|
TextColumn::make('review_pack_state')
|
||||||
->label(__('localization.review.review_pack'))
|
->label('Review pack')
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('tenant_id')
|
||||||
->label(__('localization.review.tenant'))
|
->label('Tenant')
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
->query(function (Builder $query, array $data): Builder {
|
->query(function (Builder $query, array $data): Builder {
|
||||||
@ -178,25 +148,25 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('open_latest_review')
|
Action::make('open_latest_review')
|
||||||
->label(__('localization.review.open_latest_review'))
|
->label('Open latest review')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||||
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
||||||
Action::make('download_review_pack')
|
Action::make('download_review_pack')
|
||||||
->label(__('localization.review.download_review_pack'))
|
->label('Download review pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
->emptyStateHeading('No entitled tenants match this view')
|
||||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||||
? __('localization.review.clear_filters_description')
|
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
|
||||||
: __('localization.review.adjust_filters_description'))
|
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
Action::make('clear_filters_empty')
|
Action::make('clear_filters_empty')
|
||||||
->label(__('localization.review.clear_filters'))
|
->label('Clear filters')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
@ -417,7 +387,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
|||||||
|
|
||||||
private function latestReviewStateLabel(Tenant $tenant): string
|
private function latestReviewStateLabel(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewStateColor(Tenant $tenant): string
|
private function latestReviewStateColor(Tenant $tenant): string
|
||||||
@ -440,7 +410,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
|||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof TenantReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return 'No published review available yet';
|
||||||
}
|
}
|
||||||
|
|
||||||
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
||||||
@ -457,7 +427,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
|||||||
return $primaryReason;
|
return $primaryReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findingSummary(Tenant $tenant): string
|
private function findingSummary(Tenant $tenant): string
|
||||||
@ -465,7 +435,7 @@ private function findingSummary(Tenant $tenant): string
|
|||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof TenantReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return 'No published review available yet';
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
@ -474,17 +444,14 @@ private function findingSummary(Tenant $tenant): string
|
|||||||
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||||
|
|
||||||
if ($findingCount === 0) {
|
if ($findingCount === 0) {
|
||||||
return __('localization.review.no_findings_recorded');
|
return 'No findings recorded in the published review.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($terminalOutcomes === null) {
|
if ($terminalOutcomes === null) {
|
||||||
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
|
return sprintf('%d findings summarized in the published review.', $findingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return __('localization.review.findings_count_with_outcomes', [
|
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
|
||||||
'count' => $findingCount,
|
|
||||||
'outcomes' => $terminalOutcomes,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function acceptedRiskSummary(Tenant $tenant): string
|
private function acceptedRiskSummary(Tenant $tenant): string
|
||||||
@ -492,7 +459,7 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
|||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
if (! $review instanceof TenantReview) {
|
||||||
return __('localization.review.no_published_review_available');
|
return 'No published review available yet';
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
@ -502,10 +469,10 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
|||||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||||
|
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
$statusMarkedCount === 0 => 'No accepted risks recorded.',
|
||||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
|
||||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
|
||||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,17 +481,17 @@ private function reviewPackAvailability(Tenant $tenant): string
|
|||||||
$pack = $this->latestReviewPack($tenant);
|
$pack = $this->latestReviewPack($tenant);
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack) {
|
if (! $pack instanceof ReviewPack) {
|
||||||
return __('localization.review.unavailable');
|
return 'Unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
return __('localization.review.unavailable');
|
return 'Unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||||
return __('localization.review.unavailable');
|
return 'Unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
return __('localization.review.available');
|
return 'Available';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,7 +12,6 @@
|
|||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -59,7 +58,6 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
private const SETTING_FIELDS = [
|
private const SETTING_FIELDS = [
|
||||||
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
||||||
'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'],
|
|
||||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||||
@ -155,22 +153,17 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('save')
|
Action::make('save')
|
||||||
->label(__('localization.workspace.save'))
|
->label('Save')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->save();
|
$this->save();
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
||||||
? null
|
? null
|
||||||
: __('localization.workspace.no_manage_permission')),
|
: 'You do not have permission to manage workspace settings.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return __('localization.workspace.title');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
@ -215,18 +208,6 @@ public function content(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->statePath('data')
|
->statePath('data')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make(__('localization.workspace.section'))
|
|
||||||
->description($this->sectionDescription('localization', __('localization.workspace.section_description')))
|
|
||||||
->schema([
|
|
||||||
Select::make('localization_default_locale')
|
|
||||||
->label(__('localization.workspace.default_locale_label'))
|
|
||||||
->options(LocaleResolver::localeOptions())
|
|
||||||
->placeholder(__('localization.workspace.default_locale_placeholder'))
|
|
||||||
->native(false)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->localeDefaultHelperText())
|
|
||||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
|
||||||
]),
|
|
||||||
Section::make('Workspace entitlements')
|
Section::make('Workspace entitlements')
|
||||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -526,7 +507,7 @@ public function save(): void
|
|||||||
$this->loadFormState();
|
$this->loadFormState();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
|
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
@ -545,7 +526,7 @@ public function resetSetting(string $field): void
|
|||||||
|
|
||||||
if ($this->workspaceOverrideForField($field) === null) {
|
if ($this->workspaceOverrideForField($field) === null) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.notifications.setting_already_default'))
|
->title('Setting already uses default')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -562,7 +543,7 @@ public function resetSetting(string $field): void
|
|||||||
$this->loadFormState();
|
$this->loadFormState();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.notifications.workspace_setting_reset'))
|
->title('Workspace setting reset to default')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
@ -711,17 +692,18 @@ private function sectionDescription(string $domain, string $baseDescription): st
|
|||||||
/** @var Carbon $updatedAt */
|
/** @var Carbon $updatedAt */
|
||||||
$updatedAt = $meta['updated_at'];
|
$updatedAt = $meta['updated_at'];
|
||||||
|
|
||||||
return __('localization.workspace.last_modified_by', [
|
return sprintf(
|
||||||
'description' => $baseDescription,
|
'%s — Last modified by %s, %s.',
|
||||||
'user' => $meta['user_name'],
|
$baseDescription,
|
||||||
'time' => $updatedAt->diffForHumans(),
|
$meta['user_name'],
|
||||||
]);
|
$updatedAt->diffForHumans(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makeResetAction(string $field): Action
|
private function makeResetAction(string $field): Action
|
||||||
{
|
{
|
||||||
return Action::make('reset_'.$field)
|
return Action::make('reset_'.$field)
|
||||||
->label(__('localization.workspace.reset'))
|
->label('Reset')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function () use ($field): void {
|
->action(function () use ($field): void {
|
||||||
@ -736,15 +718,15 @@ private function makeResetAction(string $field): Action
|
|||||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
||||||
->tooltip(function () use ($field): ?string {
|
->tooltip(function () use ($field): ?string {
|
||||||
if (! $this->currentUserCanManage()) {
|
if (! $this->currentUserCanManage()) {
|
||||||
return __('localization.workspace.no_manage_permission');
|
return 'You do not have permission to manage workspace settings.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->canResetField($field)) {
|
if (! $this->canResetField($field)) {
|
||||||
if ($this->isEntitlementOverrideValueField($field)) {
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
return __('localization.workspace.no_workspace_override');
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return __('localization.workspace.no_workspace_override');
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -966,29 +948,6 @@ private function helperTextFor(string $field): string
|
|||||||
return sprintf('Effective value: %s.', $effectiveValue);
|
return sprintf('Effective value: %s.', $effectiveValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function localeDefaultHelperText(): string
|
|
||||||
{
|
|
||||||
$resolved = $this->resolvedSettings['localization_default_locale'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($resolved)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en';
|
|
||||||
$localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale);
|
|
||||||
|
|
||||||
if (! $this->hasWorkspaceOverride('localization_default_locale')) {
|
|
||||||
return __('localization.workspace.default_locale_helper_unset', [
|
|
||||||
'locale' => $localeLabel,
|
|
||||||
'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return __('localization.workspace.default_locale_helper_set', [
|
|
||||||
'locale' => $localeLabel,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function slaFieldHelperText(string $severity): string
|
private function slaFieldHelperText(string $severity): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
||||||
@ -1394,9 +1353,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
|
|||||||
private function sourceLabel(string $source): string
|
private function sourceLabel(string $source): string
|
||||||
{
|
{
|
||||||
return match ($source) {
|
return match ($source) {
|
||||||
'workspace_override' => __('localization.source.workspace_override'),
|
'workspace_override' => 'workspace override',
|
||||||
'tenant_override' => 'tenant override',
|
'tenant_override' => 'tenant override',
|
||||||
default => __('localization.source.system_default'),
|
default => 'system default',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,11 +42,6 @@ class TenantDashboard extends Dashboard
|
|||||||
*/
|
*/
|
||||||
public array $supportDiagnosticsAuditKeys = [];
|
public array $supportDiagnosticsAuditKeys = [];
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return __('localization.dashboard.tenant_title');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed> $parameters
|
* @param array<mixed> $parameters
|
||||||
*/
|
*/
|
||||||
@ -95,38 +90,38 @@ public function authorizeTenantSupportRequest(): void
|
|||||||
private function requestSupportAction(): Action
|
private function requestSupportAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('requestSupport')
|
$action = Action::make('requestSupport')
|
||||||
->label(__('localization.dashboard.request_support'))
|
->label('Request support')
|
||||||
->icon('heroicon-o-paper-airplane')
|
->icon('heroicon-o-paper-airplane')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->slideOver()
|
->slideOver()
|
||||||
->stickyModalHeader()
|
->stickyModalHeader()
|
||||||
->modalHeading(__('localization.dashboard.support_request_heading'))
|
->modalHeading('Request support')
|
||||||
->modalDescription(__('localization.dashboard.support_request_description'))
|
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
|
||||||
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
|
->modalSubmitActionLabel('Submit request')
|
||||||
->form([
|
->form([
|
||||||
Placeholder::make('included_context')
|
Placeholder::make('included_context')
|
||||||
->label(__('localization.dashboard.included_context'))
|
->label('Included context')
|
||||||
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Select::make('severity')
|
Select::make('severity')
|
||||||
->label(__('localization.dashboard.severity'))
|
->label('Severity')
|
||||||
->options(SupportRequest::severityOptions())
|
->options(SupportRequest::severityOptions())
|
||||||
->default(SupportRequest::SEVERITY_NORMAL)
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
->required()
|
->required()
|
||||||
->native(false),
|
->native(false),
|
||||||
TextInput::make('summary')
|
TextInput::make('summary')
|
||||||
->label(__('localization.dashboard.summary'))
|
->label('Summary')
|
||||||
->required()
|
->required()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('reproduction_notes')
|
Textarea::make('reproduction_notes')
|
||||||
->label(__('localization.dashboard.reproduction_notes'))
|
->label('Reproduction notes')
|
||||||
->rows(4)
|
->rows(4)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
TextInput::make('contact_name')
|
TextInput::make('contact_name')
|
||||||
->label(__('localization.dashboard.contact_name'))
|
->label('Contact name')
|
||||||
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
||||||
TextInput::make('contact_email')
|
TextInput::make('contact_email')
|
||||||
->label(__('localization.dashboard.contact_email'))
|
->label('Contact email')
|
||||||
->email()
|
->email()
|
||||||
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
||||||
])
|
])
|
||||||
@ -137,7 +132,7 @@ private function requestSupportAction(): Action
|
|||||||
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.dashboard.support_request_submitted'))
|
->title('Support request submitted')
|
||||||
->body('Reference '.$supportRequest->internal_reference)
|
->body('Reference '.$supportRequest->internal_reference)
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
@ -151,16 +146,16 @@ private function requestSupportAction(): Action
|
|||||||
private function openSupportDiagnosticsAction(): Action
|
private function openSupportDiagnosticsAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('openSupportDiagnostics')
|
$action = Action::make('openSupportDiagnostics')
|
||||||
->label(__('localization.dashboard.open_support_diagnostics'))
|
->label('Open support diagnostics')
|
||||||
->icon('heroicon-o-lifebuoy')
|
->icon('heroicon-o-lifebuoy')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->modal()
|
->modal()
|
||||||
->slideOver()
|
->slideOver()
|
||||||
->stickyModalHeader()
|
->stickyModalHeader()
|
||||||
->modalHeading(__('localization.dashboard.support_diagnostics'))
|
->modalHeading('Support diagnostics')
|
||||||
->modalDescription(__('localization.dashboard.support_diagnostics_description'))
|
->modalDescription('Redacted tenant context from existing records.')
|
||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||||
->mountUsing(function (): void {
|
->mountUsing(function (): void {
|
||||||
$this->auditTenantSupportDiagnosticsOpen();
|
$this->auditTenantSupportDiagnosticsOpen();
|
||||||
})
|
})
|
||||||
|
|||||||
@ -75,6 +75,8 @@ class FindingResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Findings';
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
@ -84,26 +86,6 @@ public static function shouldRegisterNavigation(): bool
|
|||||||
return parent::shouldRegisterNavigation();
|
return parent::shouldRegisterNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.navigation.findings');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
|
||||||
{
|
|
||||||
return __('localization.navigation.governance');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getModelLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.navigation.findings');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPluralModelLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.navigation.findings');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -308,6 +290,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
|
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
|
||||||
: null)
|
: null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||||
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
|
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
|
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
|
||||||
@ -998,6 +982,7 @@ public static function table(Table $table): Table
|
|||||||
if (! in_array((string) $record->status, [
|
if (! in_array((string) $record->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true)) {
|
], true)) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
@ -1413,6 +1398,7 @@ public static function triageAction(): Actions\Action
|
|||||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
@ -1437,6 +1423,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
|
|||||||
@ -10,8 +10,14 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -71,15 +77,15 @@ public function getTabs(): array
|
|||||||
$stats = FindingResource::findingStatsForCurrentTenant();
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'all' => Tab::make(__('localization.findings.all'))
|
'all' => Tab::make('All')
|
||||||
->icon('heroicon-m-list-bullet'),
|
->icon('heroicon-m-list-bullet'),
|
||||||
'needs_action' => Tab::make(__('localization.findings.needs_action'))
|
'needs_action' => Tab::make('Needs action')
|
||||||
->icon('heroicon-m-exclamation-triangle')
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
->whereIn('status', Finding::openStatusesForQuery()))
|
->whereIn('status', Finding::openStatusesForQuery()))
|
||||||
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||||
->badgeColor('warning'),
|
->badgeColor('warning'),
|
||||||
'overdue' => Tab::make(__('localization.findings.overdue'))
|
'overdue' => Tab::make('Overdue')
|
||||||
->icon('heroicon-m-clock')
|
->icon('heroicon-m-clock')
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
@ -87,11 +93,11 @@ public function getTabs(): array
|
|||||||
->where('due_at', '<', now()))
|
->where('due_at', '<', now()))
|
||||||
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||||
->badgeColor('danger'),
|
->badgeColor('danger'),
|
||||||
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
|
'risk_accepted' => Tab::make('Risk accepted')
|
||||||
->icon('heroicon-m-shield-check')
|
->icon('heroicon-m-shield-check')
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||||
'resolved' => Tab::make(__('localization.findings.resolved'))
|
'resolved' => Tab::make('Resolved')
|
||||||
->icon('heroicon-m-archive-box')
|
->icon('heroicon-m-archive-box')
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||||
@ -102,6 +108,77 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
$actions = [];
|
$actions = [];
|
||||||
|
|
||||||
|
$actions[] = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('backfill_lifecycle')
|
||||||
|
->label('Backfill findings lifecycle')
|
||||||
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Backfill findings lifecycle')
|
||||||
|
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||||
|
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$opRun = $runbookService->start(
|
||||||
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
|
initiator: $user,
|
||||||
|
reason: null,
|
||||||
|
source: 'tenant_ui',
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||||
|
|
||||||
|
if ($opRun->wasRecentlyCreated === false) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('Open operation')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('Open operation')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
|
||||||
$actions[] = UiEnforcement::forAction(
|
$actions[] = UiEnforcement::forAction(
|
||||||
Actions\Action::make('triage_all_matching')
|
Actions\Action::make('triage_all_matching')
|
||||||
->label('Triage all matching')
|
->label('Triage all matching')
|
||||||
@ -171,6 +248,7 @@ protected function getHeaderActions(): array
|
|||||||
if (! in_array((string) $finding->status, [
|
if (! in_array((string) $finding->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true)) {
|
], true)) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ protected function getHeaderActions(): array
|
|||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Actions\Action::make('open_approval_queue')
|
Actions\Action::make('open_approval_queue')
|
||||||
->label(__('localization.findings.open_approval_queue'))
|
->label('Open approval queue')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
|
|||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
->label(__('localization.findings.actions'))
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -85,26 +85,6 @@ public static function shouldRegisterNavigation(): bool
|
|||||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.reporting');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.reviews');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getModelLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.review');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPluralModelLabel(): string
|
|
||||||
{
|
|
||||||
return __('localization.review.reviews');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -173,7 +153,7 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
Section::make(__('localization.review.outcome_summary'))
|
Section::make('Outcome summary')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('artifact_truth')
|
ViewEntry::make('artifact_truth')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
@ -182,7 +162,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make(__('localization.review.review'))
|
Section::make('Review')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -191,23 +171,23 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||||
TextEntry::make('completeness_state')
|
TextEntry::make('completeness_state')
|
||||||
->label(__('localization.review.completeness'))
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
|
TextEntry::make('tenant.name')->label('Tenant'),
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
TextEntry::make('evidenceSnapshot.id')
|
||||||
->label(__('localization.review.evidence_snapshot'))
|
->label('Evidence snapshot')
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||||
: null),
|
: null),
|
||||||
TextEntry::make('currentExportReviewPack.id')
|
TextEntry::make('currentExportReviewPack.id')
|
||||||
->label(__('localization.review.current_export'))
|
->label('Current export')
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||||
@ -221,7 +201,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make(__('localization.review.executive_posture'))
|
Section::make('Executive posture')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('review_summary')
|
ViewEntry::make('review_summary')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
@ -230,21 +210,21 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make(__('localization.review.sections'))
|
Section::make('Sections')
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('sections')
|
RepeatableEntry::make('sections')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('title'),
|
TextEntry::make('title'),
|
||||||
TextEntry::make('completeness_state')
|
TextEntry::make('completeness_state')
|
||||||
->label(__('localization.review.completeness'))
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||||
Section::make(__('localization.review.details'))
|
Section::make('Details')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('section_payload')
|
ViewEntry::make('section_payload')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
@ -266,7 +246,7 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label(__('localization.review.export_executive_pack'))
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
@ -298,7 +278,7 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->label(__('localization.review.outcome'))
|
->label('Outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
||||||
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
||||||
@ -309,10 +289,10 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||||
->label(__('localization.review.export'))
|
->label('Export')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
Tables\Columns\TextColumn::make('next_step')
|
Tables\Columns\TextColumn::make('next_step')
|
||||||
->label(__('localization.review.next_step'))
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
|
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
@ -326,18 +306,18 @@ public static function table(Table $table): Table
|
|||||||
->all()),
|
->all()),
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
$exportExecutivePackAction,
|
$exportExecutivePackAction,
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
|
->emptyStateHeading('No tenant reviews yet')
|
||||||
->emptyStateDescription(__('localization.review.create_first_review_description'))
|
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
static::makeCreateReviewAction(
|
static::makeCreateReviewAction(
|
||||||
name: 'create_first_review',
|
name: 'create_first_review',
|
||||||
label: __('localization.review.create_first_review'),
|
label: 'Create first review',
|
||||||
icon: 'heroicon-o-plus',
|
icon: 'heroicon-o-plus',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@ -356,23 +336,19 @@ public static function makeCreateReviewAction(
|
|||||||
string $label = 'Create review',
|
string $label = 'Create review',
|
||||||
string $icon = 'heroicon-o-plus',
|
string $icon = 'heroicon-o-plus',
|
||||||
): Actions\Action {
|
): Actions\Action {
|
||||||
$label = $label === 'Create review'
|
|
||||||
? __('localization.review.create_review')
|
|
||||||
: $label;
|
|
||||||
|
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make($name)
|
Actions\Action::make($name)
|
||||||
->label($label)
|
->label($label)
|
||||||
->icon($icon)
|
->icon($icon)
|
||||||
->form([
|
->form([
|
||||||
Section::make(__('localization.review.evidence_basis'))
|
Section::make('Evidence basis')
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('evidence_snapshot_id')
|
Select::make('evidence_snapshot_id')
|
||||||
->label(__('localization.review.evidence_snapshot'))
|
->label('Evidence snapshot')
|
||||||
->required()
|
->required()
|
||||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
->helperText(__('localization.review.evidence_basis_helper')),
|
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||||
@ -390,7 +366,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
|
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -412,7 +388,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
|
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -420,7 +396,7 @@ public static function executeCreateReview(array $data): void
|
|||||||
try {
|
try {
|
||||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -430,11 +406,11 @@ public static function executeCreateReview(array $data): void
|
|||||||
if (! $review->wasRecentlyCreated) {
|
if (! $review->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title(__('localization.review.review_already_available'))
|
->title('Review already available')
|
||||||
->body(__('localization.review.review_already_available_body'))
|
->body('A matching mutable review already exists for this evidence basis.')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_review')
|
Actions\Action::make('view_review')
|
||||||
->label(__('localization.review.view_review'))
|
->label('View review')
|
||||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -443,12 +419,12 @@ public static function executeCreateReview(array $data): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||||
->body(__('localization.review.review_composing_background'));
|
->body('The review is being composed in the background.');
|
||||||
|
|
||||||
if ($review->operation_run_id) {
|
if ($review->operation_run_id) {
|
||||||
$toast->actions([
|
$toast->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label(__('localization.review.open_operation'))
|
->label('Open operation')
|
||||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -520,7 +496,7 @@ public static function executeExport(TenantReview $review): void
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||||
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
|
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -537,7 +513,7 @@ public static function executeExport(TenantReview $review): void
|
|||||||
|
|
||||||
if ($service->checkActiveRunForReview($review)) {
|
if ($service->checkActiveRunForReview($review)) {
|
||||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||||
->body(__('localization.review.export_already_queued_body'))
|
->body('An executive pack export is already queued or running for this review.')
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -549,11 +525,11 @@ public static function executeExport(TenantReview $review): void
|
|||||||
'include_operations' => true,
|
'include_operations' => true,
|
||||||
]);
|
]);
|
||||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
|
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -564,11 +540,11 @@ public static function executeExport(TenantReview $review): void
|
|||||||
if (! $pack->wasRecentlyCreated) {
|
if (! $pack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title(__('localization.review.executive_pack_already_available'))
|
->title('Executive pack already available')
|
||||||
->body(__('localization.review.executive_pack_already_available_body'))
|
->body('A matching executive pack already exists for this review.')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_pack')
|
Actions\Action::make('view_pack')
|
||||||
->label(__('localization.review.view_pack'))
|
->label('View pack')
|
||||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -577,7 +553,7 @@ public static function executeExport(TenantReview $review): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||||
->body(__('localization.review.executive_pack_generating_background'))
|
->body('The executive pack is being generated in the background.')
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -617,7 +593,7 @@ private static function evidenceSnapshotOptions(): array
|
|||||||
'#%d · %s · %s',
|
'#%d · %s · %s',
|
||||||
(int) $snapshot->getKey(),
|
(int) $snapshot->getKey(),
|
||||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
|
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
@ -641,7 +617,7 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||||
|
|
||||||
if ($findingOutcomeSummary !== null) {
|
if ($findingOutcomeSummary !== null) {
|
||||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -653,12 +629,12 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
'context_links' => static::summaryContextLinks($record),
|
'context_links' => static::summaryContextLinks($record),
|
||||||
'metrics' => [
|
'metrics' => [
|
||||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||||
['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
|
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||||
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||||
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -672,37 +648,37 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
|
|
||||||
if (is_numeric($record->operation_run_id)) {
|
if (is_numeric($record->operation_run_id)) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.operation'),
|
'title' => 'Operation',
|
||||||
'label' => __('localization.review.open_operation'),
|
'label' => 'Open operation',
|
||||||
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||||
'description' => __('localization.review.operation_description'),
|
'description' => 'Inspect the latest review composition or refresh run.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->currentExportReviewPack && $record->tenant) {
|
if ($record->currentExportReviewPack && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.executive_pack'),
|
'title' => 'Executive pack',
|
||||||
'label' => __('localization.review.view_executive_pack'),
|
'label' => 'View executive pack',
|
||||||
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||||
'description' => __('localization.review.executive_pack_description'),
|
'description' => 'Open the current export that belongs to this review.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.customer_workspace'),
|
'title' => 'Customer workspace',
|
||||||
'label' => __('localization.review.open_customer_workspace'),
|
'label' => 'Open customer workspace',
|
||||||
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||||
'description' => __('localization.review.customer_workspace_description'),
|
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->evidenceSnapshot && $record->tenant) {
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.evidence_snapshot'),
|
'title' => 'Evidence snapshot',
|
||||||
'label' => __('localization.review.view_evidence_snapshot'),
|
'label' => 'View evidence snapshot',
|
||||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||||
'description' => __('localization.review.evidence_snapshot_description'),
|
'description' => 'Return to the evidence basis behind this review.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,11 +28,6 @@ class Dashboard extends BaseDashboard
|
|||||||
{
|
{
|
||||||
public string $window = SystemConsoleWindow::LastDay;
|
public string $window = SystemConsoleWindow::LastDay;
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return __('localization.dashboard.system_title');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed> $parameters
|
* @param array<mixed> $parameters
|
||||||
*/
|
*/
|
||||||
@ -114,12 +109,12 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
Action::make('set_window')
|
Action::make('set_window')
|
||||||
->label(__('localization.dashboard.time_window'))
|
->label('Time window')
|
||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->form([
|
->form([
|
||||||
Select::make('window')
|
Select::make('window')
|
||||||
->label(__('localization.dashboard.window'))
|
->label('Window')
|
||||||
->options(SystemConsoleWindow::options())
|
->options(SystemConsoleWindow::options())
|
||||||
->default($this->window)
|
->default($this->window)
|
||||||
->required(),
|
->required(),
|
||||||
@ -135,7 +130,7 @@ protected function getHeaderActions(): array
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
Action::make('enter_break_glass')
|
Action::make('enter_break_glass')
|
||||||
->label(__('localization.dashboard.enter_break_glass'))
|
->label('Enter break-glass mode')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -163,13 +158,13 @@ protected function getHeaderActions(): array
|
|||||||
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.dashboard.recovery_mode_enabled'))
|
->title('Recovery mode enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Action::make('exit_break_glass')
|
Action::make('exit_break_glass')
|
||||||
->label(__('localization.dashboard.exit_break_glass'))
|
->label('Exit break-glass')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -185,7 +180,7 @@ protected function getHeaderActions(): array
|
|||||||
$breakGlass->exit($user);
|
$breakGlass->exit($user);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.dashboard.recovery_mode_ended'))
|
->title('Recovery mode ended')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -57,6 +57,11 @@ public static function canAccess(): bool
|
|||||||
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
|
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort_unless(static::canAccess(), 403);
|
||||||
|
}
|
||||||
|
|
||||||
public function getHeader(): ?View
|
public function getHeader(): ?View
|
||||||
{
|
{
|
||||||
return view('filament.system.pages.ops.partials.controls-header', [
|
return view('filament.system.pages.ops.partials.controls-header', [
|
||||||
|
|||||||
@ -4,9 +4,26 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Ops;
|
namespace App\Filament\System\Pages\Ops;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Services\Runbooks\RunbookReason;
|
||||||
|
use App\Services\System\AllowedTenantUniverse;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Radio;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class Runbooks extends Page
|
class Runbooks extends Page
|
||||||
{
|
{
|
||||||
@ -20,6 +37,53 @@ class Runbooks extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.runbooks';
|
protected string $view = 'filament.system.pages.ops.runbooks';
|
||||||
|
|
||||||
|
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||||
|
|
||||||
|
public ?int $findingsTenantId = null;
|
||||||
|
|
||||||
|
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||||
|
|
||||||
|
public ?int $tenantId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||||
|
*/
|
||||||
|
public ?array $findingsPreflight = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||||
|
*/
|
||||||
|
public ?array $preflight = null;
|
||||||
|
|
||||||
|
public function findingsScopeLabel(): string
|
||||||
|
{
|
||||||
|
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
|
||||||
|
return 'All tenants';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantName = $this->selectedTenantName($this->findingsTenantId);
|
||||||
|
|
||||||
|
if ($tenantName !== null) {
|
||||||
|
return "Single tenant ({$tenantName})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findingsLastRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedTenantName(?int $tenantId): ?string
|
||||||
|
{
|
||||||
|
if ($tenantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()->whereKey($tenantId)->value('name');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
@ -31,4 +95,231 @@ public static function canAccess(): bool
|
|||||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('preflight')
|
||||||
|
->label('Preflight')
|
||||||
|
->color('gray')
|
||||||
|
->icon('heroicon-o-magnifying-glass')
|
||||||
|
->form($this->findingsScopeForm())
|
||||||
|
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||||
|
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
||||||
|
|
||||||
|
$this->findingsScopeMode = $scope->mode;
|
||||||
|
$this->findingsTenantId = $scope->tenantId;
|
||||||
|
$this->scopeMode = $scope->mode;
|
||||||
|
$this->tenantId = $scope->tenantId;
|
||||||
|
|
||||||
|
$this->findingsPreflight = $runbookService->preflight($scope);
|
||||||
|
$this->preflight = $this->findingsPreflight;
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Preflight complete')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('run')
|
||||||
|
->label('Run…')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Run: Rebuild Findings Lifecycle')
|
||||||
|
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
|
||||||
|
->form($this->findingsRunForm())
|
||||||
|
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||||
|
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||||
|
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'preflight' => 'Run preflight first.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
||||||
|
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN)
|
||||||
|
|| ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL)
|
||||||
|
) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope->isAllTenants()) {
|
||||||
|
$typedConfirmation = (string) ($data['typed_confirmation'] ?? '');
|
||||||
|
|
||||||
|
if ($typedConfirmation !== 'BACKFILL') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'typed_confirmation' => 'Please type BACKFILL to confirm.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = RunbookReason::fromNullableArray([
|
||||||
|
'reason_code' => $data['reason_code'] ?? null,
|
||||||
|
'reason_text' => $data['reason_text'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $runbookService->start(
|
||||||
|
scope: $scope,
|
||||||
|
initiator: $user,
|
||||||
|
reason: $reason,
|
||||||
|
source: 'system_ui',
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewUrl = SystemOperationRunLinks::view($run);
|
||||||
|
|
||||||
|
$toast = $run->wasRecentlyCreated
|
||||||
|
? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.')
|
||||||
|
: OperationUxPresenter::alreadyQueuedToast((string) $run->type);
|
||||||
|
|
||||||
|
$toast
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($viewUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function findingsScopeForm(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Radio::make('scope_mode')
|
||||||
|
->label('Scope')
|
||||||
|
->options([
|
||||||
|
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
|
||||||
|
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
||||||
|
])
|
||||||
|
->default($this->findingsScopeMode)
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
Select::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||||
|
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||||
|
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
|
||||||
|
return $universe
|
||||||
|
->query()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $universe
|
||||||
|
->query()
|
||||||
|
->whereKey((int) $value)
|
||||||
|
->value('name');
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function findingsRunForm(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TextInput::make('typed_confirmation')
|
||||||
|
->label('Type BACKFILL to confirm')
|
||||||
|
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||||
|
->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||||
|
->in(['BACKFILL'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type BACKFILL to confirm.',
|
||||||
|
]),
|
||||||
|
|
||||||
|
Select::make('reason_code')
|
||||||
|
->label('Reason code')
|
||||||
|
->options(RunbookReason::options())
|
||||||
|
->required(function (BreakGlassSession $breakGlass): bool {
|
||||||
|
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Textarea::make('reason_text')
|
||||||
|
->label('Reason')
|
||||||
|
->rows(4)
|
||||||
|
->maxLength(500)
|
||||||
|
->required(function (BreakGlassSession $breakGlass): bool {
|
||||||
|
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastRunForType(string $type): ?OperationRun
|
||||||
|
{
|
||||||
|
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $platformTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $platformTenant->workspace_id)
|
||||||
|
->where('type', $type)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||||
|
{
|
||||||
|
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||||
|
'mode' => $data['scope_mode'] ?? null,
|
||||||
|
'tenant_id' => $data['tenant_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $scope->isSingleTenant()) {
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
|
||||||
|
|
||||||
|
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||||
|
{
|
||||||
|
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
|
||||||
|
return FindingsLifecycleBackfillScope::allTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
|
||||||
|
|
||||||
|
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LocalizationController extends Controller
|
|
||||||
{
|
|
||||||
public function context(Request $request, LocaleResolver $resolver): JsonResponse
|
|
||||||
{
|
|
||||||
$plane = $request->query('plane');
|
|
||||||
$context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
|
|
||||||
|
|
||||||
if (is_string($plane) && $plane !== '') {
|
|
||||||
$context = $resolver->resolve($request, $plane);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(is_array($context) ? $context : $resolver->resolve($request));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateOverride(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$locale = LocaleResolver::normalize($request->input('locale'));
|
|
||||||
|
|
||||||
if ($locale === null) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'locale' => [__('localization.validation.unsupported_locale')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale);
|
|
||||||
App::setLocale($locale);
|
|
||||||
|
|
||||||
return back()->with('status', __('localization.notifications.locale_override_saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
|
|
||||||
App::setLocale($resolver->resolve($request)['locale']);
|
|
||||||
|
|
||||||
return back()->with('status', __('localization.notifications.locale_override_cleared'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
abort_unless($user instanceof User, Response::HTTP_NOT_FOUND);
|
|
||||||
|
|
||||||
$rawLocale = $request->input('preferred_locale');
|
|
||||||
$locale = $rawLocale === null || $rawLocale === ''
|
|
||||||
? null
|
|
||||||
: LocaleResolver::normalize($rawLocale);
|
|
||||||
|
|
||||||
if ($rawLocale !== null && $rawLocale !== '' && $locale === null) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'preferred_locale' => [__('localization.validation.unsupported_locale')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->forceFill(['preferred_locale' => $locale])->save();
|
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
App::setLocale($resolver->resolve($request)['locale']);
|
|
||||||
|
|
||||||
return back()->with('status', $locale === null
|
|
||||||
? __('localization.notifications.user_preference_cleared')
|
|
||||||
: __('localization.notifications.user_preference_saved'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class ApplyResolvedLocale
|
|
||||||
{
|
|
||||||
public function __construct(private LocaleResolver $resolver) {}
|
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next, ?string $plane = null): Response
|
|
||||||
{
|
|
||||||
$context = $this->resolver->resolve($request, $plane);
|
|
||||||
|
|
||||||
App::setLocale($context['locale']);
|
|
||||||
Carbon::setLocale($context['locale']);
|
|
||||||
|
|
||||||
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingSlaPolicy;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class BackfillFindingLifecycleJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $tenantId,
|
||||||
|
public readonly int $workspaceId,
|
||||||
|
public readonly ?int $initiatorUserId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationRunService $operationRuns,
|
||||||
|
FindingSlaPolicy $slaPolicy,
|
||||||
|
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $this->initiatorUserId !== null
|
||||||
|
? User::query()->find($this->initiatorUserId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$operationRun = $operationRuns->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'findings.lifecycle.backfill',
|
||||||
|
identityInputs: [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'trigger' => 'backfill',
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'workspace_id' => $this->workspaceId,
|
||||||
|
'initiator_user_id' => $this->initiatorUserId,
|
||||||
|
],
|
||||||
|
initiator: $initiator instanceof User ? $initiator : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
if ($operationRun->status !== OperationRunStatus::Completed->value) {
|
||||||
|
$operationRuns->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Blocked->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||||
|
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$runbookService->maybeFinalize($operationRun);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$total = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$operationRuns->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $total,
|
||||||
|
'processed' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$operationRun->refresh();
|
||||||
|
|
||||||
|
$backfillStartedAt = $operationRun->started_at !== null
|
||||||
|
? CarbonImmutable::instance($operationRun->started_at)
|
||||||
|
: CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
|
Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void {
|
||||||
|
$processed = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$originalAttributes = $finding->getAttributes();
|
||||||
|
|
||||||
|
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||||
|
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||||
|
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||||
|
$this->backfillDriftRecurrenceKey($finding);
|
||||||
|
|
||||||
|
if ($finding->isDirty()) {
|
||||||
|
$finding->save();
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||||
|
'processed' => $processed,
|
||||||
|
'updated' => $updated,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||||
|
|
||||||
|
if ($consolidatedDuplicates > 0) {
|
||||||
|
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||||
|
'updated' => $consolidatedDuplicates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRuns->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
$runbookService->maybeFinalize($operationRun);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||||
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||||
|
|
||||||
|
$operationRuns->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'findings.lifecycle.backfill.failed',
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$runbookService->maybeFinalize($operationRun);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||||
|
{
|
||||||
|
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||||
|
|
||||||
|
if ($finding->first_seen_at === null) {
|
||||||
|
$finding->first_seen_at = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->last_seen_at === null) {
|
||||||
|
$finding->last_seen_at = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||||
|
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||||
|
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||||
|
|
||||||
|
if ($lastSeen->lessThan($firstSeen)) {
|
||||||
|
$finding->last_seen_at = $firstSeen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||||
|
|
||||||
|
if ($timesSeen < 1) {
|
||||||
|
$finding->times_seen = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||||
|
{
|
||||||
|
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->status = Finding::STATUS_TRIAGED;
|
||||||
|
|
||||||
|
if ($finding->triaged_at === null) {
|
||||||
|
if ($finding->acknowledged_at !== null) {
|
||||||
|
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||||
|
} elseif ($finding->created_at !== null) {
|
||||||
|
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillSlaFields(
|
||||||
|
Finding $finding,
|
||||||
|
Tenant $tenant,
|
||||||
|
FindingSlaPolicy $slaPolicy,
|
||||||
|
CarbonImmutable $backfillStartedAt,
|
||||||
|
): void {
|
||||||
|
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->sla_days === null) {
|
||||||
|
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at === null) {
|
||||||
|
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||||
|
{
|
||||||
|
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||||
|
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||||
|
$subjectType = (string) ($finding->subject_type ?? '');
|
||||||
|
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||||
|
|
||||||
|
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||||
|
$kind = Arr::get($evidence, 'summary.kind');
|
||||||
|
$changeType = Arr::get($evidence, 'change_type');
|
||||||
|
|
||||||
|
$kind = is_string($kind) ? $kind : '';
|
||||||
|
$changeType = is_string($changeType) ? $changeType : '';
|
||||||
|
|
||||||
|
if ($kind === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||||
|
|
||||||
|
$finding->recurrence_key = hash('sha256', sprintf(
|
||||||
|
'drift:%d:%s:%s:%s:%s',
|
||||||
|
$tenantId,
|
||||||
|
$scopeKey,
|
||||||
|
$subjectType,
|
||||||
|
$subjectExternalId,
|
||||||
|
$dimension,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recurrenceDimension(string $kind, string $changeType): string
|
||||||
|
{
|
||||||
|
$kind = strtolower(trim($kind));
|
||||||
|
$changeType = strtolower(trim($changeType));
|
||||||
|
|
||||||
|
return match ($kind) {
|
||||||
|
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||||
|
default => $kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||||
|
{
|
||||||
|
$duplicateKeys = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->whereNotNull('recurrence_key')
|
||||||
|
->select(['recurrence_key'])
|
||||||
|
->groupBy('recurrence_key')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->pluck('recurrence_key')
|
||||||
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($duplicateKeys->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consolidated = 0;
|
||||||
|
|
||||||
|
foreach ($duplicateKeys as $recurrenceKey) {
|
||||||
|
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('recurrence_key', $recurrenceKey)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||||
|
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'status' => Finding::STATUS_CLOSED,
|
||||||
|
'resolved_at' => null,
|
||||||
|
'resolved_reason' => null,
|
||||||
|
'closed_at' => $backfillStartedAt,
|
||||||
|
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||||
|
'closed_by_user_id' => null,
|
||||||
|
'recurrence_key' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$consolidated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $consolidated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Finding> $findings
|
||||||
|
*/
|
||||||
|
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||||
|
{
|
||||||
|
if ($findings->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||||
|
|
||||||
|
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||||
|
|
||||||
|
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||||
|
|
||||||
|
if ($alreadyCanonical instanceof Finding) {
|
||||||
|
return $alreadyCanonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Finding $sorted */
|
||||||
|
$sorted = $candidates
|
||||||
|
->sortByDesc(function (Finding $finding): array {
|
||||||
|
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||||
|
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
max($lastSeen, $createdAt),
|
||||||
|
(int) $finding->getKey(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Findings\FindingSlaPolicy;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $operationRunId,
|
||||||
|
public readonly int $workspaceId,
|
||||||
|
public readonly int $tenantId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationRunService $operationRunService,
|
||||||
|
FindingSlaPolicy $slaPolicy,
|
||||||
|
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->workspace_id !== $this->workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()->find($this->operationRunId);
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->tenant_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->status === 'queued') {
|
||||||
|
$operationRunService->updateRun($run, status: 'running');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
$operationRunService->appendFailures($run, [
|
||||||
|
[
|
||||||
|
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||||
|
'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->incrementSummaryCounts($run, [
|
||||||
|
'failed' => 1,
|
||||||
|
'processed' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->maybeCompleteBulkRun($run);
|
||||||
|
$runbookService->maybeFinalize($run);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$backfillStartedAt = $run->started_at !== null
|
||||||
|
? CarbonImmutable::instance($run->started_at)
|
||||||
|
: CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
|
Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void {
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalAttributes = $finding->getAttributes();
|
||||||
|
|
||||||
|
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||||
|
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||||
|
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||||
|
$this->backfillDriftRecurrenceKey($finding);
|
||||||
|
|
||||||
|
if ($finding->isDirty()) {
|
||||||
|
$finding->save();
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updated > 0 || $skipped > 0) {
|
||||||
|
$operationRunService->incrementSummaryCounts($run, [
|
||||||
|
'updated' => $updated,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||||
|
|
||||||
|
if ($consolidatedDuplicates > 0) {
|
||||||
|
$operationRunService->incrementSummaryCounts($run, [
|
||||||
|
'updated' => $consolidatedDuplicates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRunService->incrementSummaryCounts($run, [
|
||||||
|
'processed' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->maybeCompleteBulkRun($run);
|
||||||
|
$runbookService->maybeFinalize($run);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||||
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||||
|
|
||||||
|
$operationRunService->appendFailures($run, [[
|
||||||
|
'code' => 'findings.lifecycle.backfill.failed',
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId),
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$operationRunService->incrementSummaryCounts($run, [
|
||||||
|
'failed' => 1,
|
||||||
|
'processed' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->maybeCompleteBulkRun($run);
|
||||||
|
$runbookService->maybeFinalize($run);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||||
|
{
|
||||||
|
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||||
|
|
||||||
|
if ($finding->first_seen_at === null) {
|
||||||
|
$finding->first_seen_at = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->last_seen_at === null) {
|
||||||
|
$finding->last_seen_at = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||||
|
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||||
|
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||||
|
|
||||||
|
if ($lastSeen->lessThan($firstSeen)) {
|
||||||
|
$finding->last_seen_at = $firstSeen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||||
|
|
||||||
|
if ($timesSeen < 1) {
|
||||||
|
$finding->times_seen = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||||
|
{
|
||||||
|
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->status = Finding::STATUS_TRIAGED;
|
||||||
|
|
||||||
|
if ($finding->triaged_at === null) {
|
||||||
|
if ($finding->acknowledged_at !== null) {
|
||||||
|
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||||
|
} elseif ($finding->created_at !== null) {
|
||||||
|
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillSlaFields(
|
||||||
|
Finding $finding,
|
||||||
|
Tenant $tenant,
|
||||||
|
FindingSlaPolicy $slaPolicy,
|
||||||
|
CarbonImmutable $backfillStartedAt,
|
||||||
|
): void {
|
||||||
|
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->sla_days === null) {
|
||||||
|
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at === null) {
|
||||||
|
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||||
|
{
|
||||||
|
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||||
|
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||||
|
$subjectType = (string) ($finding->subject_type ?? '');
|
||||||
|
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||||
|
|
||||||
|
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||||
|
$kind = Arr::get($evidence, 'summary.kind');
|
||||||
|
$changeType = Arr::get($evidence, 'change_type');
|
||||||
|
|
||||||
|
$kind = is_string($kind) ? $kind : '';
|
||||||
|
$changeType = is_string($changeType) ? $changeType : '';
|
||||||
|
|
||||||
|
if ($kind === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||||
|
|
||||||
|
$finding->recurrence_key = hash('sha256', sprintf(
|
||||||
|
'drift:%d:%s:%s:%s:%s',
|
||||||
|
$tenantId,
|
||||||
|
$scopeKey,
|
||||||
|
$subjectType,
|
||||||
|
$subjectExternalId,
|
||||||
|
$dimension,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recurrenceDimension(string $kind, string $changeType): string
|
||||||
|
{
|
||||||
|
$kind = strtolower(trim($kind));
|
||||||
|
$changeType = strtolower(trim($changeType));
|
||||||
|
|
||||||
|
return match ($kind) {
|
||||||
|
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||||
|
default => $kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||||
|
{
|
||||||
|
$duplicateKeys = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->whereNotNull('recurrence_key')
|
||||||
|
->select(['recurrence_key'])
|
||||||
|
->groupBy('recurrence_key')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->pluck('recurrence_key')
|
||||||
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($duplicateKeys->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consolidated = 0;
|
||||||
|
|
||||||
|
foreach ($duplicateKeys as $recurrenceKey) {
|
||||||
|
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('recurrence_key', $recurrenceKey)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||||
|
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'status' => Finding::STATUS_CLOSED,
|
||||||
|
'resolved_at' => null,
|
||||||
|
'resolved_reason' => null,
|
||||||
|
'closed_at' => $backfillStartedAt,
|
||||||
|
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||||
|
'closed_by_user_id' => null,
|
||||||
|
'recurrence_key' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$consolidated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $consolidated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Finding> $findings
|
||||||
|
*/
|
||||||
|
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||||
|
{
|
||||||
|
if ($findings->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||||
|
|
||||||
|
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||||
|
|
||||||
|
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||||
|
|
||||||
|
if ($alreadyCanonical instanceof Finding) {
|
||||||
|
return $alreadyCanonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Finding $sorted */
|
||||||
|
$sorted = $candidates
|
||||||
|
->sortByDesc(function (Finding $finding): array {
|
||||||
|
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||||
|
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
max($lastSeen, $createdAt),
|
||||||
|
(int) $finding->getKey(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\System\AllowedTenantUniverse;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $operationRunId,
|
||||||
|
public readonly int $workspaceId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationRunService $operationRunService,
|
||||||
|
AllowedTenantUniverse $allowedTenantUniverse,
|
||||||
|
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||||
|
): void {
|
||||||
|
$run = OperationRun::query()->find($this->operationRunId);
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->tenant_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $allowedTenantUniverse
|
||||||
|
->query()
|
||||||
|
->where('workspace_id', $this->workspaceId)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$tenantCount = count($tenantIds);
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$run,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'tenants' => $tenantCount,
|
||||||
|
'total' => $tenantCount,
|
||||||
|
'processed' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($tenantCount === 0) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
$runbookService->maybeFinalize($run);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tenantIds as $tenantId) {
|
||||||
|
if ($tenantId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch(
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
workspaceId: $this->workspaceId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,8 @@ class Finding extends Model
|
|||||||
|
|
||||||
public const string STATUS_NEW = 'new';
|
public const string STATUS_NEW = 'new';
|
||||||
|
|
||||||
|
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||||
|
|
||||||
public const string STATUS_TRIAGED = 'triaged';
|
public const string STATUS_TRIAGED = 'triaged';
|
||||||
|
|
||||||
public const string STATUS_IN_PROGRESS = 'in_progress';
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||||
@ -167,7 +169,10 @@ public static function terminalStatuses(): array
|
|||||||
*/
|
*/
|
||||||
public static function openStatusesForQuery(): array
|
public static function openStatusesForQuery(): array
|
||||||
{
|
{
|
||||||
return self::openStatuses();
|
return [
|
||||||
|
...self::openStatuses(),
|
||||||
|
self::STATUS_ACKNOWLEDGED,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -290,6 +295,10 @@ public static function isReopenReason(?string $reason): bool
|
|||||||
|
|
||||||
public static function canonicalizeStatus(?string $status): ?string
|
public static function canonicalizeStatus(?string $status): ?string
|
||||||
{
|
{
|
||||||
|
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||||
|
return self::STATUS_TRIAGED;
|
||||||
|
}
|
||||||
|
|
||||||
return $status;
|
return $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +324,23 @@ public function isRiskAccepted(): bool
|
|||||||
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function acknowledge(User $user): self
|
||||||
|
{
|
||||||
|
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'status' => self::STATUS_ACKNOWLEDGED,
|
||||||
|
'acknowledged_at' => now(),
|
||||||
|
'acknowledged_by_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function resolve(string $reason): self
|
public function resolve(string $reason): self
|
||||||
{
|
{
|
||||||
$this->forceFill([
|
$this->forceFill([
|
||||||
|
|||||||
@ -39,7 +39,6 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
|
|||||||
'password',
|
'password',
|
||||||
'entra_tenant_id',
|
'entra_tenant_id',
|
||||||
'entra_object_id',
|
'entra_object_id',
|
||||||
'preferred_locale',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -49,7 +49,10 @@ public function update(User $user, Finding $finding): Response|bool
|
|||||||
|
|
||||||
public function triage(User $user, Finding $finding): Response|bool
|
public function triage(User $user, Finding $finding): Response|bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE);
|
return $this->canMutateWithAnyCapability($user, $finding, [
|
||||||
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assign(User $user, Finding $finding): Response|bool
|
public function assign(User $user, Finding $finding): Response|bool
|
||||||
|
|||||||
@ -79,16 +79,16 @@ public function panel(Panel $panel): Panel
|
|||||||
])
|
])
|
||||||
->navigationItems([
|
->navigationItems([
|
||||||
WorkspaceOverview::navigationItem(),
|
WorkspaceOverview::navigationItem(),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
|
NavigationItem::make('Integrations')
|
||||||
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
|
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
|
||||||
->icon('heroicon-o-link')
|
->icon('heroicon-o-link')
|
||||||
->group(fn (): string => __('localization.navigation.settings'))
|
->group('Settings')
|
||||||
->sort(15)
|
->sort(15)
|
||||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
|
NavigationItem::make('Settings')
|
||||||
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
||||||
->icon('heroicon-o-cog-6-tooth')
|
->icon('heroicon-o-cog-6-tooth')
|
||||||
->group(fn (): string => __('localization.navigation.settings'))
|
->group('Settings')
|
||||||
->sort(20)
|
->sort(20)
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -115,12 +115,12 @@ public function panel(Panel $panel): Panel
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
||||||
}),
|
}),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
|
NavigationItem::make('Manage workspaces')
|
||||||
->url(function (): string {
|
->url(function (): string {
|
||||||
return route('filament.admin.resources.workspaces.index');
|
return route('filament.admin.resources.workspaces.index');
|
||||||
})
|
})
|
||||||
->icon('heroicon-o-squares-2x2')
|
->icon('heroicon-o-squares-2x2')
|
||||||
->group(fn (): string => __('localization.navigation.settings'))
|
->group('Settings')
|
||||||
->sort(10)
|
->sort(10)
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -136,15 +136,15 @@ public function panel(Panel $panel): Panel
|
|||||||
->whereIn('role', $roles)
|
->whereIn('role', $roles)
|
||||||
->exists();
|
->exists();
|
||||||
}),
|
}),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
NavigationItem::make('Operations')
|
||||||
->url(fn (): string => route('admin.operations.index'))
|
->url(fn (): string => route('admin.operations.index'))
|
||||||
->icon('heroicon-o-queue-list')
|
->icon('heroicon-o-queue-list')
|
||||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
->group('Monitoring')
|
||||||
->sort(10),
|
->sort(10),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
NavigationItem::make('Audit Log')
|
||||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||||
->icon('heroicon-o-clipboard-document-list')
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
->group('Monitoring')
|
||||||
->sort(30),
|
->sort(30),
|
||||||
])
|
])
|
||||||
->renderHook(
|
->renderHook(
|
||||||
@ -210,7 +210,6 @@ public function panel(Panel $panel): Panel
|
|||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
])
|
])
|
||||||
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
|
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -42,14 +42,6 @@ public function panel(Panel $panel): Panel
|
|||||||
PanelsRenderHook::BODY_START,
|
PanelsRenderHook::BODY_START,
|
||||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
)
|
)
|
||||||
->renderHook(
|
|
||||||
PanelsRenderHook::TOPBAR_START,
|
|
||||||
fn () => view('filament.partials.locale-switcher', [
|
|
||||||
'plane' => 'system',
|
|
||||||
'showPreference' => false,
|
|
||||||
'embedded' => false,
|
|
||||||
])->render(),
|
|
||||||
)
|
|
||||||
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
|
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
Dashboard::class,
|
Dashboard::class,
|
||||||
@ -67,7 +59,6 @@ public function panel(Panel $panel): Panel
|
|||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
])
|
])
|
||||||
->middleware(['apply-resolved-locale:system'], isPersistent: true)
|
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
|||||||
@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
|
|||||||
'primary' => Color::Indigo,
|
'primary' => Color::Indigo,
|
||||||
])
|
])
|
||||||
->navigationItems([
|
->navigationItems([
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
NavigationItem::make(OperationRunLinks::collectionLabel())
|
||||||
->url(fn (): string => route('admin.operations.index'))
|
->url(fn (): string => route('admin.operations.index'))
|
||||||
->icon('heroicon-o-queue-list')
|
->icon('heroicon-o-queue-list')
|
||||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
->group('Monitoring')
|
||||||
->sort(10),
|
->sort(10),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
|
NavigationItem::make('Alerts')
|
||||||
->url(fn (): string => url('/admin/alerts'))
|
->url(fn (): string => url('/admin/alerts'))
|
||||||
->icon('heroicon-o-bell-alert')
|
->icon('heroicon-o-bell-alert')
|
||||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
->group('Monitoring')
|
||||||
->sort(20),
|
->sort(20),
|
||||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
NavigationItem::make('Audit Log')
|
||||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||||
->icon('heroicon-o-clipboard-document-list')
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
->group('Monitoring')
|
||||||
->sort(30),
|
->sort(30),
|
||||||
])
|
])
|
||||||
->renderHook(
|
->renderHook(
|
||||||
@ -111,7 +111,6 @@ public function panel(Panel $panel): Panel
|
|||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
])
|
])
|
||||||
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
|
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_RESOLVE,
|
Capabilities::TENANT_FINDINGS_RESOLVE,
|
||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
@ -73,6 +74,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_RESOLVE,
|
Capabilities::TENANT_FINDINGS_RESOLVE,
|
||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
@ -110,6 +112,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
|
|||||||
@ -46,13 +46,17 @@ public static function meaningfulActivityActionValues(): array
|
|||||||
|
|
||||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
$this->authorize($finding, $tenant, $actor, [
|
||||||
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
]);
|
||||||
|
|
||||||
$currentStatus = (string) $finding->status;
|
$currentStatus = (string) $finding->status;
|
||||||
|
|
||||||
if (! in_array($currentStatus, [
|
if (! in_array($currentStatus, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true)) {
|
], true)) {
|
||||||
throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
|
throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
|
||||||
}
|
}
|
||||||
@ -78,9 +82,12 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
|||||||
|
|
||||||
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
$this->authorize($finding, $tenant, $actor, [
|
||||||
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
]);
|
||||||
|
|
||||||
if ((string) $finding->status !== Finding::STATUS_TRIAGED) {
|
if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) {
|
||||||
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
|
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,7 +369,10 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
|||||||
|
|
||||||
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
$this->authorize($finding, $tenant, $actor, [
|
||||||
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
]);
|
||||||
|
|
||||||
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
|
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
|
||||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||||
|
|||||||
@ -1,215 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Localization;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class LocaleResolver
|
|
||||||
{
|
|
||||||
public const SESSION_OVERRIDE_KEY = 'tenantpilot.locale_override';
|
|
||||||
|
|
||||||
public const REQUEST_ATTRIBUTE = 'tenantpilot.resolved_locale';
|
|
||||||
|
|
||||||
public const SETTING_DOMAIN = 'localization';
|
|
||||||
|
|
||||||
public const SETTING_DEFAULT_LOCALE = 'default_locale';
|
|
||||||
|
|
||||||
public const SOURCE_EXPLICIT_OVERRIDE = 'explicit_override';
|
|
||||||
|
|
||||||
public const SOURCE_USER_PREFERENCE = 'user_preference';
|
|
||||||
|
|
||||||
public const SOURCE_WORKSPACE_DEFAULT = 'workspace_default';
|
|
||||||
|
|
||||||
public const SOURCE_SYSTEM_DEFAULT = 'system_default';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const SUPPORTED_LOCALES = ['en', 'de'];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private SettingsResolver $settingsResolver,
|
|
||||||
private WorkspaceContext $workspaceContext,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function supportedLocales(): array
|
|
||||||
{
|
|
||||||
return self::SUPPORTED_LOCALES;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function localeOptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'en' => __('localization.locales.en'),
|
|
||||||
'de' => __('localization.locales.de'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isSupported(mixed $locale): bool
|
|
||||||
{
|
|
||||||
return self::normalize($locale) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function normalize(mixed $locale): ?string
|
|
||||||
{
|
|
||||||
if (! is_string($locale)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = strtolower(trim($locale));
|
|
||||||
|
|
||||||
return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* locale: string,
|
|
||||||
* source: string,
|
|
||||||
* fallback_locale: string,
|
|
||||||
* user_preference_locale: ?string,
|
|
||||||
* workspace_default_locale: ?string,
|
|
||||||
* machine_artifacts_invariant: true
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolve(Request $request, ?string $plane = null): array
|
|
||||||
{
|
|
||||||
$plane = $this->normalizePlane($plane, $request);
|
|
||||||
|
|
||||||
$explicitOverride = $this->explicitOverride($request);
|
|
||||||
$systemDefault = (string) config('app.fallback_locale', 'en');
|
|
||||||
|
|
||||||
if ($plane === 'system') {
|
|
||||||
return $this->resolveFromSources(
|
|
||||||
explicitOverride: $explicitOverride,
|
|
||||||
userPreference: null,
|
|
||||||
workspaceDefault: null,
|
|
||||||
systemDefault: $systemDefault,
|
|
||||||
includeUserPreference: false,
|
|
||||||
includeWorkspaceDefault: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
$userPreference = $user instanceof User ? $user->preferred_locale : null;
|
|
||||||
$workspaceDefault = $this->workspaceDefault($request);
|
|
||||||
|
|
||||||
return $this->resolveFromSources(
|
|
||||||
explicitOverride: $explicitOverride,
|
|
||||||
userPreference: $userPreference,
|
|
||||||
workspaceDefault: $workspaceDefault,
|
|
||||||
systemDefault: $systemDefault,
|
|
||||||
includeUserPreference: true,
|
|
||||||
includeWorkspaceDefault: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* locale: string,
|
|
||||||
* source: string,
|
|
||||||
* fallback_locale: string,
|
|
||||||
* user_preference_locale: ?string,
|
|
||||||
* workspace_default_locale: ?string,
|
|
||||||
* machine_artifacts_invariant: true
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolveFromSources(
|
|
||||||
mixed $explicitOverride,
|
|
||||||
mixed $userPreference,
|
|
||||||
mixed $workspaceDefault,
|
|
||||||
mixed $systemDefault,
|
|
||||||
bool $includeUserPreference = true,
|
|
||||||
bool $includeWorkspaceDefault = true,
|
|
||||||
): array {
|
|
||||||
$fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en';
|
|
||||||
|
|
||||||
$candidates = [
|
|
||||||
self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($includeUserPreference) {
|
|
||||||
$candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($includeWorkspaceDefault) {
|
|
||||||
$candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault);
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale;
|
|
||||||
|
|
||||||
foreach ($candidates as $source => $locale) {
|
|
||||||
if ($locale !== null) {
|
|
||||||
return [
|
|
||||||
'locale' => $locale,
|
|
||||||
'source' => $source,
|
|
||||||
'fallback_locale' => $fallbackLocale,
|
|
||||||
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
|
|
||||||
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
|
|
||||||
'machine_artifacts_invariant' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'locale' => $fallbackLocale,
|
|
||||||
'source' => self::SOURCE_SYSTEM_DEFAULT,
|
|
||||||
'fallback_locale' => $fallbackLocale,
|
|
||||||
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
|
|
||||||
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
|
|
||||||
'machine_artifacts_invariant' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function explicitOverride(Request $request): ?string
|
|
||||||
{
|
|
||||||
$queryLocale = self::normalize($request->query('locale'));
|
|
||||||
|
|
||||||
if ($queryLocale !== null) {
|
|
||||||
return $queryLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $request->hasSession()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspaceDefault(Request $request): ?string
|
|
||||||
{
|
|
||||||
$workspace = $this->workspaceContext->currentWorkspace($request);
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::normalize($this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_DEFAULT_LOCALE,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizePlane(?string $plane, Request $request): string
|
|
||||||
{
|
|
||||||
$plane = strtolower(trim((string) $plane));
|
|
||||||
|
|
||||||
if (in_array($plane, ['admin', 'tenant', 'system'], true)) {
|
|
||||||
return $plane;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $request->is('system', 'system/*') ? 'system' : 'admin';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,739 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Runbooks;
|
||||||
|
|
||||||
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
|
use App\Jobs\BackfillFindingLifecycleWorkspaceJob;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Notifications\OperationRunCompleted;
|
||||||
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
|
use App\Services\Audit\AuditRecorder;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\System\AllowedTenantUniverse;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Audit\AuditActorSnapshot;
|
||||||
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class FindingsLifecycleBackfillRunbookService
|
||||||
|
{
|
||||||
|
public const string RUNBOOK_KEY = 'findings.lifecycle.backfill';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AllowedTenantUniverse $allowedTenantUniverse,
|
||||||
|
private readonly BreakGlassSession $breakGlassSession,
|
||||||
|
private readonly OperationRunService $operationRunService,
|
||||||
|
private readonly AuditLogger $auditLogger,
|
||||||
|
private readonly AlertDispatchService $alertDispatchService,
|
||||||
|
private readonly OperationalControlEvaluator $operationalControls,
|
||||||
|
private readonly AuditRecorder $auditRecorder,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
|
||||||
|
*/
|
||||||
|
public function preflight(FindingsLifecycleBackfillScope $scope): array
|
||||||
|
{
|
||||||
|
$result = $this->computePreflight($scope);
|
||||||
|
|
||||||
|
$this->auditSafely(
|
||||||
|
action: 'platform.ops.runbooks.preflight',
|
||||||
|
scope: $scope,
|
||||||
|
operationRunId: null,
|
||||||
|
initiator: null,
|
||||||
|
context: [
|
||||||
|
'preflight' => $result,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(
|
||||||
|
FindingsLifecycleBackfillScope $scope,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
?RunbookReason $reason,
|
||||||
|
string $source,
|
||||||
|
): OperationRun {
|
||||||
|
$source = trim($source);
|
||||||
|
|
||||||
|
if ($source === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'source' => 'A run source is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isBreakGlassActive = $this->breakGlassSession->isActive();
|
||||||
|
|
||||||
|
if ($scope->isAllTenants() || $isBreakGlassActive) {
|
||||||
|
if (! $reason instanceof RunbookReason) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'A reason is required for this run.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$preflight = $this->computePreflight($scope);
|
||||||
|
|
||||||
|
if (($preflight['affected_count'] ?? 0) <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'preflight.affected_count' => 'Nothing to do for this scope.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = null;
|
||||||
|
$tenant = null;
|
||||||
|
|
||||||
|
if ($scope->isSingleTenant()) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||||
|
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||||
|
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
} else {
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$workspace = $platformTenant->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new \RuntimeException('Platform tenant is missing its workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
|
||||||
|
|
||||||
|
if ($decision->isPaused()) {
|
||||||
|
$this->auditBlockedStart(
|
||||||
|
decision: $decision,
|
||||||
|
scope: $scope,
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $initiator,
|
||||||
|
source: $source,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw OperationalControlBlockedException::forDecision(
|
||||||
|
decision: $decision,
|
||||||
|
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope->isAllTenants()) {
|
||||||
|
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
|
||||||
|
$lock = Cache::lock($lockKey, 900);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope' => 'Another run is already in progress for this scope.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->startAllTenants(
|
||||||
|
workspace: $workspace,
|
||||||
|
initiator: $initiator,
|
||||||
|
reason: $reason,
|
||||||
|
preflight: $preflight,
|
||||||
|
source: $source,
|
||||||
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->startSingleTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $initiator,
|
||||||
|
reason: $reason,
|
||||||
|
preflight: $preflight,
|
||||||
|
source: $source,
|
||||||
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybeFinalize(OperationRun $run): void
|
||||||
|
{
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey());
|
||||||
|
$lock = Cache::lock($lockKey, 86400);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->auditSafely(
|
||||||
|
action: $run->outcome === OperationRunOutcome::Failed->value
|
||||||
|
? 'platform.ops.runbooks.failed'
|
||||||
|
: 'platform.ops.runbooks.completed',
|
||||||
|
scope: $this->scopeFromRunContext($context),
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
context: [
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false),
|
||||||
|
'reason_code' => data_get($context, 'reason.reason_code'),
|
||||||
|
'reason_text' => data_get($context, 'reason.reason_text'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->notifyInitiatorSafely($run);
|
||||||
|
|
||||||
|
if ($run->outcome === OperationRunOutcome::Failed->value) {
|
||||||
|
$this->dispatchFailureAlertSafely($run);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
|
||||||
|
*/
|
||||||
|
private function computePreflight(FindingsLifecycleBackfillScope $scope): array
|
||||||
|
{
|
||||||
|
if ($scope->isSingleTenant()) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||||
|
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||||
|
|
||||||
|
return $this->computeTenantPreflight($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$workspaceId = (int) ($platformTenant->workspace_id ?? 0);
|
||||||
|
|
||||||
|
$tenants = $this->allowedTenantUniverse
|
||||||
|
->query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$affected = 0;
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $this->computeTenantPreflight($tenant);
|
||||||
|
|
||||||
|
$affected += (int) ($counts['affected_count'] ?? 0);
|
||||||
|
$total += (int) ($counts['total_count'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'affected_count' => $affected,
|
||||||
|
'total_count' => $total,
|
||||||
|
'estimated_tenants' => $tenants->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{affected_count: int, total_count: int}
|
||||||
|
*/
|
||||||
|
private function computeTenantPreflight(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$query = Finding::query()->where('tenant_id', (int) $tenant->getKey());
|
||||||
|
|
||||||
|
$total = (int) (clone $query)->count();
|
||||||
|
|
||||||
|
$affected = 0;
|
||||||
|
|
||||||
|
(clone $query)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($findings) use (&$affected): void {
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->findingNeedsBackfill($finding)) {
|
||||||
|
$affected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$affected += $this->countDriftDuplicateConsolidations($tenant);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'affected_count' => $affected,
|
||||||
|
'total_count' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingNeedsBackfill(Finding $finding): bool
|
||||||
|
{
|
||||||
|
if ($finding->first_seen_at === null || $finding->last_seen_at === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||||
|
if ($finding->last_seen_at->lt($finding->first_seen_at)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||||
|
|
||||||
|
if ($timesSeen < 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->status === Finding::STATUS_ACKNOWLEDGED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Finding::isOpenStatus((string) $finding->status)) {
|
||||||
|
if ($finding->sla_days === null || $finding->due_at === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) {
|
||||||
|
$recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : '';
|
||||||
|
|
||||||
|
if ($recurrenceKey === '') {
|
||||||
|
$scopeKey = trim((string) ($finding->scope_key ?? ''));
|
||||||
|
$subjectType = trim((string) ($finding->subject_type ?? ''));
|
||||||
|
$subjectExternalId = trim((string) ($finding->subject_external_id ?? ''));
|
||||||
|
|
||||||
|
if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') {
|
||||||
|
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||||
|
$kind = data_get($evidence, 'summary.kind');
|
||||||
|
|
||||||
|
if (is_string($kind) && trim($kind) !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countDriftDuplicateConsolidations(Tenant $tenant): int
|
||||||
|
{
|
||||||
|
$rows = Finding::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->whereNotNull('recurrence_key')
|
||||||
|
->select(['recurrence_key', DB::raw('COUNT(*) as count')])
|
||||||
|
->groupBy('recurrence_key')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$duplicates = 0;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$count = is_numeric($row->count ?? null) ? (int) $row->count : 0;
|
||||||
|
|
||||||
|
if ($count > 1) {
|
||||||
|
$duplicates += ($count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $duplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function startAllTenants(
|
||||||
|
Workspace $workspace,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
?RunbookReason $reason,
|
||||||
|
array $preflight,
|
||||||
|
string $source,
|
||||||
|
bool $isBreakGlassActive,
|
||||||
|
): OperationRun {
|
||||||
|
$run = $this->operationRunService->ensureWorkspaceRunWithIdentity(
|
||||||
|
workspace: $workspace,
|
||||||
|
type: self::RUNBOOK_KEY,
|
||||||
|
identityInputs: [
|
||||||
|
'runbook' => self::RUNBOOK_KEY,
|
||||||
|
'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||||
|
],
|
||||||
|
context: $this->buildRunContext(
|
||||||
|
workspaceId: (int) $workspace->getKey(),
|
||||||
|
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||||
|
initiator: $initiator,
|
||||||
|
reason: $reason,
|
||||||
|
preflight: $preflight,
|
||||||
|
source: $source,
|
||||||
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
|
),
|
||||||
|
initiator: $initiator instanceof User ? $initiator : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||||
|
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditSafely(
|
||||||
|
action: 'platform.ops.runbooks.start',
|
||||||
|
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
initiator: $initiator,
|
||||||
|
context: [
|
||||||
|
'preflight' => $preflight,
|
||||||
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
|
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void {
|
||||||
|
BackfillFindingLifecycleWorkspaceJob::dispatch(
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
workspaceId: (int) $workspace->getKey(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function startSingleTenant(
|
||||||
|
?Tenant $tenant,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
?RunbookReason $reason,
|
||||||
|
array $preflight,
|
||||||
|
string $source,
|
||||||
|
bool $isBreakGlassActive,
|
||||||
|
): OperationRun {
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $this->operationRunService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: self::RUNBOOK_KEY,
|
||||||
|
identityInputs: [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'trigger' => 'backfill',
|
||||||
|
],
|
||||||
|
context: $this->buildRunContext(
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
|
initiator: $initiator,
|
||||||
|
reason: $reason,
|
||||||
|
preflight: $preflight,
|
||||||
|
source: $source,
|
||||||
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
|
),
|
||||||
|
initiator: $initiator instanceof User ? $initiator : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||||
|
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditSafely(
|
||||||
|
action: 'platform.ops.runbooks.start',
|
||||||
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
initiator: $initiator,
|
||||||
|
context: [
|
||||||
|
'preflight' => $preflight,
|
||||||
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
|
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRunService->dispatchOrFail($run, function () use ($tenant): void {
|
||||||
|
BackfillFindingLifecycleJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
initiatorUserId: null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function platformTenant(): Tenant
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new \RuntimeException('Platform tenant is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildRunContext(
|
||||||
|
int $workspaceId,
|
||||||
|
FindingsLifecycleBackfillScope $scope,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
?RunbookReason $reason,
|
||||||
|
array $preflight,
|
||||||
|
string $source,
|
||||||
|
bool $isBreakGlassActive,
|
||||||
|
): array {
|
||||||
|
$context = [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'runbook' => [
|
||||||
|
'key' => self::RUNBOOK_KEY,
|
||||||
|
'scope' => $scope->mode,
|
||||||
|
'target_tenant_id' => $scope->tenantId,
|
||||||
|
'source' => $source,
|
||||||
|
],
|
||||||
|
'preflight' => [
|
||||||
|
'affected_count' => (int) ($preflight['affected_count'] ?? 0),
|
||||||
|
'total_count' => (int) ($preflight['total_count'] ?? 0),
|
||||||
|
'estimated_tenants' => $preflight['estimated_tenants'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($reason instanceof RunbookReason) {
|
||||||
|
$context['reason'] = $reason->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($initiator instanceof PlatformUser) {
|
||||||
|
$context['platform_initiator'] = [
|
||||||
|
'platform_user_id' => (int) $initiator->getKey(),
|
||||||
|
'email' => (string) $initiator->email,
|
||||||
|
'name' => (string) $initiator->name,
|
||||||
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
|
];
|
||||||
|
} elseif ($initiator instanceof User) {
|
||||||
|
$context['tenant_initiator'] = [
|
||||||
|
'user_id' => (int) $initiator->getKey(),
|
||||||
|
'email' => (string) $initiator->email,
|
||||||
|
'name' => (string) $initiator->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope
|
||||||
|
{
|
||||||
|
$scope = data_get($context, 'runbook.scope');
|
||||||
|
$tenantId = data_get($context, 'runbook.target_tenant_id');
|
||||||
|
|
||||||
|
if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) {
|
||||||
|
return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingsLifecycleBackfillScope::allTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function auditSafely(
|
||||||
|
string $action,
|
||||||
|
FindingsLifecycleBackfillScope $scope,
|
||||||
|
?int $operationRunId,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
array $context = [],
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$metadata = [
|
||||||
|
'runbook_key' => self::RUNBOOK_KEY,
|
||||||
|
'scope' => $scope->mode,
|
||||||
|
'target_tenant_id' => $scope->tenantId,
|
||||||
|
'operation_run_id' => $operationRunId,
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($initiator instanceof User && $scope->isSingleTenant()) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||||
|
] + $context,
|
||||||
|
actorId: (int) $initiator->getKey(),
|
||||||
|
actorEmail: (string) $initiator->email,
|
||||||
|
actorName: (string) $initiator->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$platformActor = $initiator instanceof PlatformUser
|
||||||
|
? $initiator
|
||||||
|
: auth('platform')->user();
|
||||||
|
|
||||||
|
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
|
||||||
|
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
|
||||||
|
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $platformTenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||||
|
] + $context,
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Audit is fail-safe (must not crash runbooks).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditBlockedStart(
|
||||||
|
\App\Support\OperationalControls\OperationalControlDecision $decision,
|
||||||
|
FindingsLifecycleBackfillScope $scope,
|
||||||
|
Workspace $workspace,
|
||||||
|
?Tenant $tenant,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
string $source,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$metadata = array_filter([
|
||||||
|
'control_key' => $decision->controlKey,
|
||||||
|
'scope_type' => $decision->matchedScopeType,
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'reason_text' => $decision->reasonText,
|
||||||
|
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||||
|
'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null,
|
||||||
|
'requested_scope' => $scope->mode,
|
||||||
|
'target_tenant_id' => $scope->tenantId,
|
||||||
|
'source' => $source,
|
||||||
|
'runbook_key' => self::RUNBOOK_KEY,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
$summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY));
|
||||||
|
|
||||||
|
if ($scope->isAllTenants()) {
|
||||||
|
$this->auditRecorder->record(
|
||||||
|
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null,
|
||||||
|
target: new AuditTargetSnapshot(
|
||||||
|
type: 'operational_control',
|
||||||
|
id: $decision->sourceActivationId,
|
||||||
|
label: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
),
|
||||||
|
outcome: 'blocked',
|
||||||
|
summary: $summary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $initiator,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'operational_control',
|
||||||
|
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||||
|
targetLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
summary: $summary,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Audit is fail-safe (must not crash runbooks).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyInitiatorSafely(OperationRun $run): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$platformUserId = data_get($run->context, 'platform_initiator.platform_user_id');
|
||||||
|
|
||||||
|
if (! is_numeric($platformUserId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first();
|
||||||
|
|
||||||
|
if (! $platformUser instanceof PlatformUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformUser->notify(new OperationRunCompleted($run));
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Notifications must not crash the runbook.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dispatchFailureAlertSafely(OperationRun $run): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$workspace = $platformTenant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->alertDispatchService->dispatchEvent($workspace, [
|
||||||
|
'tenant_id' => (int) $platformTenant->getKey(),
|
||||||
|
'event_type' => 'operations.run.failed',
|
||||||
|
'severity' => 'high',
|
||||||
|
'title' => 'Operation failed: Findings lifecycle backfill',
|
||||||
|
'body' => 'A findings lifecycle backfill run failed.',
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'operation_type' => $run->canonicalOperationType(),
|
||||||
|
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
|
||||||
|
'view_run_url' => SystemOperationRunLinks::view($run),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Alerts must not crash the runbook.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Runbooks;
|
||||||
|
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final readonly class FindingsLifecycleBackfillScope
|
||||||
|
{
|
||||||
|
public const string MODE_ALL_TENANTS = 'all_tenants';
|
||||||
|
|
||||||
|
public const string MODE_SINGLE_TENANT = 'single_tenant';
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public string $mode,
|
||||||
|
public ?int $tenantId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function allTenants(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
mode: self::MODE_ALL_TENANTS,
|
||||||
|
tenantId: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function singleTenant(int $tenantId): self
|
||||||
|
{
|
||||||
|
$tenantId = (int) $tenantId;
|
||||||
|
|
||||||
|
if ($tenantId <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope.tenant_id' => 'Select a valid tenant.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
mode: self::MODE_SINGLE_TENANT,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$mode = trim((string) ($data['mode'] ?? ''));
|
||||||
|
|
||||||
|
if ($mode === '' || $mode === self::MODE_ALL_TENANTS) {
|
||||||
|
return self::allTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode !== self::MODE_SINGLE_TENANT) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope.mode' => 'Select a valid scope mode.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $data['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($tenantId)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope.tenant_id' => 'Select a tenant.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::singleTenant((int) $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAllTenants(): bool
|
||||||
|
{
|
||||||
|
return $this->mode === self::MODE_ALL_TENANTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSingleTenant(): bool
|
||||||
|
{
|
||||||
|
return $this->mode === self::MODE_SINGLE_TENANT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ final class OperationRunTriageService
|
|||||||
'inventory.sync',
|
'inventory.sync',
|
||||||
'policy.sync',
|
'policy.sync',
|
||||||
'directory.groups.sync',
|
'directory.groups.sync',
|
||||||
|
'findings.lifecycle.backfill',
|
||||||
'rbac.health_check',
|
'rbac.health_check',
|
||||||
'entra.admin_roles.scan',
|
'entra.admin_roles.scan',
|
||||||
'tenant.review_pack.generate',
|
'tenant.review_pack.generate',
|
||||||
@ -27,6 +28,7 @@ final class OperationRunTriageService
|
|||||||
'inventory.sync',
|
'inventory.sync',
|
||||||
'policy.sync',
|
'policy.sync',
|
||||||
'directory.groups.sync',
|
'directory.groups.sync',
|
||||||
|
'findings.lifecycle.backfill',
|
||||||
'rbac.health_check',
|
'rbac.health_check',
|
||||||
'entra.admin_roles.scan',
|
'entra.admin_roles.scan',
|
||||||
'tenant.review_pack.generate',
|
'tenant.review_pack.generate',
|
||||||
|
|||||||
@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|||||||
{
|
{
|
||||||
$summary = $this->summary($findingsItem);
|
$summary = $this->summary($findingsItem);
|
||||||
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
||||||
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true))
|
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true))
|
||||||
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
|
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
|
||||||
'critical' => 4,
|
'critical' => 4,
|
||||||
'high' => 3,
|
'high' => 3,
|
||||||
|
|||||||
@ -91,6 +91,8 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept';
|
public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept';
|
||||||
|
|
||||||
|
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||||
|
|
||||||
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
|
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
|
||||||
|
|
||||||
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';
|
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';
|
||||||
|
|||||||
@ -30,6 +30,8 @@ class PlatformCapabilities
|
|||||||
|
|
||||||
public const RUNBOOKS_RUN = 'platform.runbooks.run';
|
public const RUNBOOKS_RUN = 'platform.runbooks.run';
|
||||||
|
|
||||||
|
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
|
||||||
|
|
||||||
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
|
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -796,6 +796,7 @@ private static function findingAttentionCounts(Tenant $tenant): array
|
|||||||
$activeNonNewFindingsCount = Finding::query()
|
$activeNonNewFindingsCount = Finding::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_IN_PROGRESS,
|
Finding::STATUS_IN_PROGRESS,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
|
|||||||
@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i
|
|||||||
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
|
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
|
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
|
||||||
$tenant = Tenant::query()->withTrashed()->find($tenantId);
|
$tenant = Tenant::query()->find($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
|||||||
@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function findingStatuses(): array
|
public static function findingStatuses(bool $includeLegacyAcknowledged = true): array
|
||||||
{
|
{
|
||||||
return self::badgeOptions(BadgeDomain::FindingStatus, [
|
$options = self::badgeOptions(BadgeDomain::FindingStatus, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_IN_PROGRESS,
|
Finding::STATUS_IN_PROGRESS,
|
||||||
@ -114,6 +114,21 @@ public static function findingStatuses(): array
|
|||||||
Finding::STATUS_CLOSED,
|
Finding::STATUS_CLOSED,
|
||||||
Finding::STATUS_RISK_ACCEPTED,
|
Finding::STATUS_RISK_ACCEPTED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! $includeLegacyAcknowledged) {
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Finding::STATUS_NEW => $options[Finding::STATUS_NEW],
|
||||||
|
Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED],
|
||||||
|
Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(),
|
||||||
|
Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS],
|
||||||
|
Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED],
|
||||||
|
Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED],
|
||||||
|
Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED],
|
||||||
|
Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -297,6 +312,11 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function legacyFindingAcknowledgedLabel(): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)';
|
||||||
|
}
|
||||||
|
|
||||||
private static function platformLabel(string $platform): string
|
private static function platformLabel(string $platform): string
|
||||||
{
|
{
|
||||||
return match (Str::of($platform)
|
return match (Str::of($platform)
|
||||||
|
|||||||
@ -12,6 +12,8 @@ final class TrustedStatePolicy
|
|||||||
|
|
||||||
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
|
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
|
||||||
|
|
||||||
|
public const SYSTEM_RUNBOOKS = 'system_runbooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* name: string,
|
* name: string,
|
||||||
@ -327,6 +329,92 @@ public function firstSlice(): array
|
|||||||
'scopedTenant',
|
'scopedTenant',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
self::SYSTEM_RUNBOOKS => [
|
||||||
|
'component_name' => 'System runbooks',
|
||||||
|
'plane' => 'system_platform',
|
||||||
|
'route_anchor' => null,
|
||||||
|
'authority_sources' => [
|
||||||
|
'allowed_tenant_universe',
|
||||||
|
'explicit_scoped_query',
|
||||||
|
],
|
||||||
|
'locked_identities' => [],
|
||||||
|
'locked_identity_fields' => [],
|
||||||
|
'mutable_selectors' => [
|
||||||
|
'findingsTenantId',
|
||||||
|
'tenantId',
|
||||||
|
'findingsScopeMode',
|
||||||
|
'scopeMode',
|
||||||
|
],
|
||||||
|
'mutable_selector_fields' => [
|
||||||
|
$this->field(
|
||||||
|
name: 'findingsTenantId',
|
||||||
|
stateClass: TrustedStateClass::Presentation,
|
||||||
|
phpType: '?int',
|
||||||
|
sourceOfTruth: 'allowed_tenant_universe',
|
||||||
|
usedForProtectedAction: true,
|
||||||
|
revalidationRequired: true,
|
||||||
|
implementationMarkers: [
|
||||||
|
'public ?int $findingsTenantId = null;',
|
||||||
|
'resolveAllowedOrFail($this->findingsTenantId)',
|
||||||
|
],
|
||||||
|
notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.',
|
||||||
|
),
|
||||||
|
$this->field(
|
||||||
|
name: 'tenantId',
|
||||||
|
stateClass: TrustedStateClass::Presentation,
|
||||||
|
phpType: '?int',
|
||||||
|
sourceOfTruth: 'allowed_tenant_universe',
|
||||||
|
usedForProtectedAction: false,
|
||||||
|
revalidationRequired: false,
|
||||||
|
implementationMarkers: [
|
||||||
|
'public ?int $tenantId = null;',
|
||||||
|
],
|
||||||
|
notes: 'Mirrored display state for the last trusted preflight result.',
|
||||||
|
),
|
||||||
|
$this->field(
|
||||||
|
name: 'findingsScopeMode',
|
||||||
|
stateClass: TrustedStateClass::Presentation,
|
||||||
|
phpType: 'string',
|
||||||
|
sourceOfTruth: 'presentation_only',
|
||||||
|
usedForProtectedAction: true,
|
||||||
|
revalidationRequired: true,
|
||||||
|
implementationMarkers: [
|
||||||
|
'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
|
||||||
|
'trustedFindingsScopeFromState(',
|
||||||
|
],
|
||||||
|
notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.',
|
||||||
|
),
|
||||||
|
$this->field(
|
||||||
|
name: 'scopeMode',
|
||||||
|
stateClass: TrustedStateClass::Presentation,
|
||||||
|
phpType: 'string',
|
||||||
|
sourceOfTruth: 'presentation_only',
|
||||||
|
usedForProtectedAction: false,
|
||||||
|
revalidationRequired: false,
|
||||||
|
implementationMarkers: [
|
||||||
|
'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
|
||||||
|
],
|
||||||
|
notes: 'Mirrored display state for the last trusted preflight result.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'server_derived_authority_fields' => [
|
||||||
|
$this->field(
|
||||||
|
name: 'findingsScope',
|
||||||
|
stateClass: TrustedStateClass::ServerDerivedAuthority,
|
||||||
|
phpType: 'FindingsLifecycleBackfillScope',
|
||||||
|
sourceOfTruth: 'allowed_tenant_universe',
|
||||||
|
usedForProtectedAction: true,
|
||||||
|
revalidationRequired: true,
|
||||||
|
implementationMarkers: [
|
||||||
|
'trustedFindingsScopeFromFormData(',
|
||||||
|
'trustedFindingsScopeFromState(',
|
||||||
|
'resolveAllowedOrFail(',
|
||||||
|
],
|
||||||
|
notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'forbidden_public_authority_fields' => [],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -278,6 +278,7 @@ private static function canonicalDefinitions(): array
|
|||||||
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
|
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
|
||||||
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
||||||
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
||||||
|
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,36 +290,27 @@ private static function operationAliases(): array
|
|||||||
return [
|
return [
|
||||||
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
|
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
||||||
new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true),
|
|
||||||
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
|
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
|
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
|
||||||
new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true),
|
|
||||||
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
||||||
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
|
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
|
||||||
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
|
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
|
||||||
new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true),
|
|
||||||
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
||||||
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
|
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
|
||||||
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
||||||
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
||||||
new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true),
|
|
||||||
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
||||||
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true),
|
|
||||||
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
||||||
new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true),
|
|
||||||
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
||||||
new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true),
|
|
||||||
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||||
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
|
||||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||||
new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true),
|
|
||||||
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
||||||
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
|
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
|
||||||
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
|
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
|
||||||
@ -333,13 +325,13 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||||
new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true),
|
|
||||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||||
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
|
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
|
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
||||||
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
||||||
|
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Support\Settings;
|
namespace App\Support\Settings;
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Support\Ai\AiPolicyMode;
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
|
|
||||||
@ -30,25 +29,6 @@ public function __construct()
|
|||||||
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: LocaleResolver::SETTING_DOMAIN,
|
|
||||||
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'in:'.implode(',', LocaleResolver::supportedLocales()),
|
|
||||||
],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return LocaleResolver::normalize($value);
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
$this->register(new SettingDefinition(
|
||||||
domain: 'backup',
|
domain: 'backup',
|
||||||
key: 'retention_keep_last_default',
|
key: 'retention_keep_last_default',
|
||||||
|
|||||||
@ -640,18 +640,23 @@ public static function spec195ResidualSurfaceInventory(): array
|
|||||||
'discoveryState' => 'outside_primary_discovery',
|
'discoveryState' => 'outside_primary_discovery',
|
||||||
'closureDecision' => 'separately_governed',
|
'closureDecision' => 'separately_governed',
|
||||||
'reasonCategory' => 'workflow_specific_governance',
|
'reasonCategory' => 'workflow_specific_governance',
|
||||||
'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.',
|
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.',
|
||||||
'evidence' => [
|
'evidence' => [
|
||||||
[
|
[
|
||||||
'kind' => 'feature_livewire_test',
|
'kind' => 'feature_livewire_test',
|
||||||
'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
|
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
|
||||||
'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
|
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'kind' => 'authorization_test',
|
'kind' => 'authorization_test',
|
||||||
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
|
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
|
||||||
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
|
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'kind' => 'guard_test',
|
||||||
|
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
|
||||||
|
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'followUpAction' => 'add_guard_only',
|
'followUpAction' => 'add_guard_only',
|
||||||
'mustRemainBaselineExempt' => false,
|
'mustRemainBaselineExempt' => false,
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
use App\Http\Middleware\ApplyResolvedLocale;
|
|
||||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||||
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
|
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
|
||||||
|
|
||||||
@ -25,12 +24,7 @@
|
|||||||
UseSystemSessionCookieForLivewireRequests::class,
|
UseSystemSessionCookieForLivewireRequests::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->web(append: [
|
|
||||||
ApplyResolvedLocale::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'apply-resolved-locale' => ApplyResolvedLocale::class,
|
|
||||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
||||||
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
|
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
|
||||||
|
|||||||
@ -73,6 +73,18 @@ public function permissionPosture(): static
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for legacy acknowledged findings.
|
||||||
|
*/
|
||||||
|
public function acknowledged(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
'acknowledged_at' => now(),
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for triaged findings.
|
* State for triaged findings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->string('preferred_locale', 8)
|
|
||||||
->nullable()
|
|
||||||
->after('last_workspace_id')
|
|
||||||
->index();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('preferred_locale');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -41,6 +41,7 @@ public function run(): void
|
|||||||
PlatformCapabilities::OPS_VIEW,
|
PlatformCapabilities::OPS_VIEW,
|
||||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||||
PlatformCapabilities::RUNBOOKS_RUN,
|
PlatformCapabilities::RUNBOOKS_RUN,
|
||||||
|
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||||
],
|
],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'duplicate_warning_title' => 'Warnung',
|
|
||||||
'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.',
|
|
||||||
'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.',
|
|
||||||
'stat_assigned_baseline' => 'Zugewiesene Baseline',
|
|
||||||
'stat_total_findings' => 'Findings gesamt',
|
|
||||||
'stat_last_compared' => 'Zuletzt verglichen',
|
|
||||||
'stat_last_compared_never' => 'Nie',
|
|
||||||
'stat_error' => 'Fehler',
|
|
||||||
'badge_snapshot' => 'Snapshot #:id',
|
|
||||||
'badge_coverage_ok' => 'Abdeckung: OK',
|
|
||||||
'badge_coverage_warnings' => 'Abdeckung: Warnungen',
|
|
||||||
'badge_fidelity' => 'Fidelity: :level',
|
|
||||||
'badge_evidence_gaps' => 'Evidence Gaps: :count',
|
|
||||||
'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary',
|
|
||||||
'evidence_gap_details_heading' => 'Evidence-Gap-Details',
|
|
||||||
'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, nächster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.',
|
|
||||||
'evidence_gap_search_label' => 'Gap-Details suchen',
|
|
||||||
'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen',
|
|
||||||
'evidence_gap_search_help' => 'Filtert über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.',
|
|
||||||
'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.',
|
|
||||||
'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.',
|
|
||||||
'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen Sie, ob der Inventory Sync aktuell ist.',
|
|
||||||
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Prüfen Sie, ob eine Policy erstellt werden sollte.',
|
|
||||||
'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.',
|
|
||||||
'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen Sie die betroffenen Zeilen.',
|
|
||||||
'evidence_gap_reason' => 'Grund',
|
|
||||||
'evidence_gap_reason_affected' => ':count betroffen',
|
|
||||||
'evidence_gap_reason_recorded' => ':count aufgezeichnet',
|
|
||||||
'evidence_gap_reason_missing_detail' => ':count ohne Detail',
|
|
||||||
'evidence_gap_structural' => 'Strukturell: :count',
|
|
||||||
'evidence_gap_operational' => 'Operativ: :count',
|
|
||||||
'evidence_gap_transient' => 'Temporär: :count',
|
|
||||||
'evidence_gap_bucket_structural' => ':count strukturell',
|
|
||||||
'evidence_gap_bucket_operational' => ':count operativ',
|
|
||||||
'evidence_gap_bucket_transient' => ':count temporär',
|
|
||||||
'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet',
|
|
||||||
'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.',
|
|
||||||
'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, aber Detailzeilen wurden nicht aufgezeichnet.',
|
|
||||||
'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt',
|
|
||||||
'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.',
|
|
||||||
'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence',
|
|
||||||
'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.',
|
|
||||||
'evidence_gap_policy_type' => 'Governed Subject',
|
|
||||||
'evidence_gap_subject_class' => 'Subjektklasse',
|
|
||||||
'evidence_gap_outcome' => 'Ergebnis',
|
|
||||||
'evidence_gap_next_action' => 'Nächste Aktion',
|
|
||||||
'evidence_gap_subject_key' => 'Subject Key',
|
|
||||||
'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht',
|
|
||||||
'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu prüfen.',
|
|
||||||
'comparing_indicator' => 'Vergleich läuft...',
|
|
||||||
'no_findings_all_clear' => 'Kein bestätigter Drift im letzten Vergleich',
|
|
||||||
'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich',
|
|
||||||
'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps müssen geprüft werden',
|
|
||||||
'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar',
|
|
||||||
'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen',
|
|
||||||
'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgründen unterdrückt.',
|
|
||||||
'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.',
|
|
||||||
'coverage_uncovered_label' => 'Nicht abgedeckt: :list',
|
|
||||||
'failed_title' => 'Vergleich fehlgeschlagen',
|
|
||||||
'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen Sie die Run-Details oder wiederholen Sie ihn.',
|
|
||||||
'critical_drift_title' => 'Kritischer Drift erkannt',
|
|
||||||
'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.',
|
|
||||||
'empty_no_tenant' => 'Kein Tenant ausgewählt',
|
|
||||||
'empty_no_assignment' => 'Keine Baseline zugewiesen',
|
|
||||||
'empty_no_snapshot' => 'Kein Snapshot verfügbar',
|
|
||||||
'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.',
|
|
||||||
'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen',
|
|
||||||
'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
|
|
||||||
'rbac_summary_compared' => 'Verglichen',
|
|
||||||
'rbac_summary_unchanged' => 'Unverändert',
|
|
||||||
'rbac_summary_modified' => 'Geändert',
|
|
||||||
'rbac_summary_missing' => 'Fehlend',
|
|
||||||
'rbac_summary_unexpected' => 'Unerwartet',
|
|
||||||
'no_drift_title' => 'Kein Drift erkannt',
|
|
||||||
'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.',
|
|
||||||
'coverage_warnings_title' => 'Coverage-Warnungen',
|
|
||||||
'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.',
|
|
||||||
'idle_title' => 'Bereit zum Vergleich',
|
|
||||||
'button_view_run' => 'Run anzeigen',
|
|
||||||
'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen',
|
|
||||||
'button_view_findings' => 'Alle Findings anzeigen',
|
|
||||||
'button_review_last_run' => 'Letzten Run prüfen',
|
|
||||||
];
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'drift' => [
|
|
||||||
'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift',
|
|
||||||
],
|
|
||||||
'subject_types' => [
|
|
||||||
'policy' => 'Policy',
|
|
||||||
'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition',
|
|
||||||
],
|
|
||||||
'rbac' => [
|
|
||||||
'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift',
|
|
||||||
'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstützt.',
|
|
||||||
'metadata_only' => 'Nur Metadaten geändert',
|
|
||||||
'permission_change' => 'Berechtigung geändert',
|
|
||||||
'missing' => 'Im aktuellen Tenant fehlend',
|
|
||||||
'unexpected' => 'Unerwartet im aktuellen Tenant',
|
|
||||||
'changed_fields' => 'Geänderte Felder',
|
|
||||||
'baseline' => 'Baseline',
|
|
||||||
'current' => 'Aktuell',
|
|
||||||
'absent' => 'Nicht vorhanden',
|
|
||||||
'role_source' => 'Rollenquelle',
|
|
||||||
'permission_blocks' => 'Berechtigungsblöcke',
|
|
||||||
'built_in' => 'Integriert',
|
|
||||||
'custom' => 'Benutzerdefiniert',
|
|
||||||
'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
|
|
||||||
'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstützt.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'locales' => [
|
|
||||||
'en' => 'Englisch',
|
|
||||||
'de' => 'Deutsch',
|
|
||||||
],
|
|
||||||
'source' => [
|
|
||||||
'explicit_override' => 'Sitzungsüberschreibung',
|
|
||||||
'user_preference' => 'persönliche Einstellung',
|
|
||||||
'workspace_default' => 'Workspace-Standard',
|
|
||||||
'workspace_override' => 'Workspace-Überschreibung',
|
|
||||||
'system_default' => 'Systemstandard',
|
|
||||||
],
|
|
||||||
'shell' => [
|
|
||||||
'language' => 'Sprache',
|
|
||||||
'current_language' => 'Aktuelle Sprache',
|
|
||||||
'language_source' => 'Quelle: :source',
|
|
||||||
'temporary_override' => 'Temporäre Überschreibung',
|
|
||||||
'switch_language' => 'Sprache wechseln',
|
|
||||||
'clear_override' => 'Geerbte Sprache verwenden',
|
|
||||||
'personal_preference' => 'Persönliche Einstellung',
|
|
||||||
'save_preference' => 'Einstellung speichern',
|
|
||||||
'inherit_workspace' => 'Workspace-Standard verwenden',
|
|
||||||
'workspace' => 'Workspace',
|
|
||||||
'choose_workspace' => 'Workspace auswählen',
|
|
||||||
'switch_workspace' => 'Workspace wechseln',
|
|
||||||
'workspace_home' => 'Workspace-Start',
|
|
||||||
'tenant_scope' => 'Tenant-Kontext',
|
|
||||||
'select_tenant' => 'Tenant auswählen',
|
|
||||||
'selected_tenant' => 'Ausgewählter Tenant',
|
|
||||||
'no_tenant_selected' => 'Kein Tenant ausgewählt',
|
|
||||||
'switch_tenant' => 'Tenant wechseln',
|
|
||||||
'clear_tenant_scope' => 'Tenant-Kontext löschen',
|
|
||||||
'context_unavailable' => 'Kontext nicht verfügbar',
|
|
||||||
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.',
|
|
||||||
'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.',
|
|
||||||
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.',
|
|
||||||
'view_managed_tenants' => 'Managed Tenants anzeigen',
|
|
||||||
'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
|
|
||||||
'search_tenants' => 'Tenants suchen...',
|
|
||||||
'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.',
|
|
||||||
],
|
|
||||||
'workspace' => [
|
|
||||||
'title' => 'Workspace-Einstellungen',
|
|
||||||
'save' => 'Speichern',
|
|
||||||
'reset' => 'Zurücksetzen',
|
|
||||||
'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.',
|
|
||||||
'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.',
|
|
||||||
'last_modified_by' => ':description - Zuletzt geändert von :user, :time.',
|
|
||||||
'section' => 'Lokalisierung',
|
|
||||||
'section_description' => 'Workspace-Standard für Benutzer ohne persönliche Spracheinstellung.',
|
|
||||||
'default_locale_label' => 'Standardsprache',
|
|
||||||
'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)',
|
|
||||||
'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).',
|
|
||||||
'default_locale_helper_set' => 'Effektive Sprache: :locale.',
|
|
||||||
],
|
|
||||||
'auth' => [
|
|
||||||
'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.',
|
|
||||||
'sign_in_microsoft' => 'Mit Microsoft anmelden',
|
|
||||||
'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.',
|
|
||||||
],
|
|
||||||
'navigation' => [
|
|
||||||
'findings' => 'Findings',
|
|
||||||
'settings' => 'Einstellungen',
|
|
||||||
'integrations' => 'Integrationen',
|
|
||||||
'manage_workspaces' => 'Workspaces verwalten',
|
|
||||||
'operations' => 'Operationen',
|
|
||||||
'audit_log' => 'Audit-Log',
|
|
||||||
'alerts' => 'Alerts',
|
|
||||||
'governance' => 'Governance',
|
|
||||||
'monitoring' => 'Monitoring',
|
|
||||||
'dashboard' => 'Dashboard',
|
|
||||||
],
|
|
||||||
'dashboard' => [
|
|
||||||
'tenant_title' => 'Tenant-Dashboard',
|
|
||||||
'system_title' => 'System-Dashboard',
|
|
||||||
'request_support' => 'Support anfragen',
|
|
||||||
'support_request_heading' => 'Support anfragen',
|
|
||||||
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
|
|
||||||
'submit_request' => 'Anfrage senden',
|
|
||||||
'included_context' => 'Enthaltener Kontext',
|
|
||||||
'severity' => 'Schweregrad',
|
|
||||||
'summary' => 'Zusammenfassung',
|
|
||||||
'reproduction_notes' => 'Reproduktionshinweise',
|
|
||||||
'contact_name' => 'Kontaktname',
|
|
||||||
'contact_email' => 'Kontakt-E-Mail',
|
|
||||||
'support_request_submitted' => 'Supportanfrage gesendet',
|
|
||||||
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
|
|
||||||
'support_diagnostics' => 'Supportdiagnosen',
|
|
||||||
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
|
|
||||||
'close' => 'Schließen',
|
|
||||||
'time_window' => 'Zeitfenster',
|
|
||||||
'window' => 'Fenster',
|
|
||||||
'enter_break_glass' => 'Break-Glass-Modus aktivieren',
|
|
||||||
'exit_break_glass' => 'Break-Glass beenden',
|
|
||||||
'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert',
|
|
||||||
'recovery_mode_ended' => 'Wiederherstellungsmodus beendet',
|
|
||||||
],
|
|
||||||
'review' => [
|
|
||||||
'reporting' => 'Berichte',
|
|
||||||
'customer_reviews' => 'Kundenreviews',
|
|
||||||
'customer_review_workspace' => 'Kundenreview-Workspace',
|
|
||||||
'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace',
|
|
||||||
'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
|
|
||||||
'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.',
|
|
||||||
'reviews' => 'Reviews',
|
|
||||||
'clear_filters' => 'Filter löschen',
|
|
||||||
'tenant' => 'Tenant',
|
|
||||||
'latest_review' => 'Letztes Review',
|
|
||||||
'key_findings' => 'Wichtige Findings',
|
|
||||||
'accepted_risks' => 'Akzeptierte Risiken',
|
|
||||||
'published' => 'Veröffentlicht',
|
|
||||||
'review_pack' => 'Review-Pack',
|
|
||||||
'open_latest_review' => 'Letztes Review öffnen',
|
|
||||||
'download_review_pack' => 'Review-Pack herunterladen',
|
|
||||||
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
|
|
||||||
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
|
||||||
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
|
||||||
'no_published_review' => 'Kein veröffentlichtes Review',
|
|
||||||
'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar',
|
|
||||||
'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.',
|
|
||||||
'findings_count_summary' => ':count Findings im veröffentlichten Review zusammengefasst.',
|
|
||||||
'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.',
|
|
||||||
'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.',
|
|
||||||
'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).',
|
|
||||||
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
|
|
||||||
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
|
|
||||||
'unavailable' => 'Nicht verfügbar',
|
|
||||||
'available' => 'Verfügbar',
|
|
||||||
'outcome_summary' => 'Ergebniszusammenfassung',
|
|
||||||
'review' => 'Review',
|
|
||||||
'review_date' => 'Review-Datum',
|
|
||||||
'completeness' => 'Vollständigkeit',
|
|
||||||
'evidence_snapshot' => 'Evidence-Snapshot',
|
|
||||||
'current_export' => 'Aktueller Export',
|
|
||||||
'executive_posture' => 'Executive-Status',
|
|
||||||
'sections' => 'Abschnitte',
|
|
||||||
'details' => 'Details',
|
|
||||||
'export_executive_pack' => 'Executive-Pack exportieren',
|
|
||||||
'outcome' => 'Ergebnis',
|
|
||||||
'export' => 'Export',
|
|
||||||
'next_step' => 'Nächster Schritt',
|
|
||||||
'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews',
|
|
||||||
'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.',
|
|
||||||
'create_first_review' => 'Erstes Review erstellen',
|
|
||||||
'create_review' => 'Review erstellen',
|
|
||||||
'evidence_basis' => 'Evidence-Basis',
|
|
||||||
'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.',
|
|
||||||
'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.',
|
|
||||||
'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.',
|
|
||||||
'unable_create_review' => 'Review kann nicht erstellt werden',
|
|
||||||
'review_already_available' => 'Review bereits verfügbar',
|
|
||||||
'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.',
|
|
||||||
'view_review' => 'Review anzeigen',
|
|
||||||
'open_operation' => 'Operation öffnen',
|
|
||||||
'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.',
|
|
||||||
'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.',
|
|
||||||
'export_already_queued_body' => 'Ein Executive-Pack-Export ist für dieses Review bereits eingereiht oder läuft.',
|
|
||||||
'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar',
|
|
||||||
'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden',
|
|
||||||
'executive_pack_already_available' => 'Executive-Pack bereits verfügbar',
|
|
||||||
'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.',
|
|
||||||
'view_pack' => 'Pack anzeigen',
|
|
||||||
'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.',
|
|
||||||
'review_explanation' => 'Review-Erklärung',
|
|
||||||
'reason_owner' => 'Reason Owner',
|
|
||||||
'platform_core' => 'Platform Core',
|
|
||||||
'platform_reason_family' => 'Platform-Reason-Familie',
|
|
||||||
'compatibility' => 'Kompatibilität',
|
|
||||||
'highlights' => 'Highlights',
|
|
||||||
'next_actions' => 'Nächste Aktionen',
|
|
||||||
'related_context' => 'Verwandter Kontext',
|
|
||||||
'publication_readiness' => 'Veröffentlichungsreife',
|
|
||||||
'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.',
|
|
||||||
'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.',
|
|
||||||
'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.',
|
|
||||||
'key_entries' => 'Wichtige Einträge',
|
|
||||||
'entry' => 'Eintrag',
|
|
||||||
'follow_up' => 'Follow-up',
|
|
||||||
'diagnostics' => 'Diagnosen',
|
|
||||||
'result_meaning' => 'Ergebnisbedeutung',
|
|
||||||
'result_trust' => 'Ergebnisvertrauen',
|
|
||||||
'artifact_truth' => 'Artifact Truth',
|
|
||||||
'no_action_needed' => 'Keine Aktion erforderlich',
|
|
||||||
'count' => 'Anzahl',
|
|
||||||
'guidance' => 'Orientierung',
|
|
||||||
'findings' => 'Findings',
|
|
||||||
'reports' => 'Berichte',
|
|
||||||
'operations' => 'Operationen',
|
|
||||||
'pending_verification' => 'Verifizierung ausstehend',
|
|
||||||
'verified_cleared' => 'Verifiziert bereinigt',
|
|
||||||
'terminal_outcomes' => 'Terminale Ergebnisse',
|
|
||||||
'pending' => 'Ausstehend',
|
|
||||||
'operation' => 'Operation',
|
|
||||||
'operation_description' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.',
|
|
||||||
'executive_pack' => 'Executive-Pack',
|
|
||||||
'view_executive_pack' => 'Executive-Pack anzeigen',
|
|
||||||
'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.',
|
|
||||||
'customer_workspace' => 'Kunden-Workspace',
|
|
||||||
'open_customer_workspace' => 'Kunden-Workspace öffnen',
|
|
||||||
'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.',
|
|
||||||
'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen',
|
|
||||||
'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.',
|
|
||||||
],
|
|
||||||
'findings' => [
|
|
||||||
'all' => 'Alle',
|
|
||||||
'needs_action' => 'Handlungsbedarf',
|
|
||||||
'overdue' => 'Überfällig',
|
|
||||||
'risk_accepted' => 'Risiko akzeptiert',
|
|
||||||
'resolved' => 'Gelöst',
|
|
||||||
'actions' => 'Aktionen',
|
|
||||||
'open_approval_queue' => 'Freigabewarteschlange öffnen',
|
|
||||||
],
|
|
||||||
'notifications' => [
|
|
||||||
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
|
|
||||||
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
|
|
||||||
'user_preference_saved' => 'Spracheinstellung gespeichert.',
|
|
||||||
'user_preference_cleared' => 'Spracheinstellung gelöscht.',
|
|
||||||
'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert',
|
|
||||||
'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern',
|
|
||||||
'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt',
|
|
||||||
'setting_already_default' => 'Einstellung verwendet bereits den Standard',
|
|
||||||
],
|
|
||||||
'validation' => [
|
|
||||||
'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'locales' => [
|
|
||||||
'en' => 'English',
|
|
||||||
'de' => 'German',
|
|
||||||
],
|
|
||||||
'source' => [
|
|
||||||
'explicit_override' => 'session override',
|
|
||||||
'user_preference' => 'personal preference',
|
|
||||||
'workspace_default' => 'workspace default',
|
|
||||||
'workspace_override' => 'workspace override',
|
|
||||||
'system_default' => 'system default',
|
|
||||||
],
|
|
||||||
'shell' => [
|
|
||||||
'language' => 'Language',
|
|
||||||
'current_language' => 'Current language',
|
|
||||||
'language_source' => 'Source: :source',
|
|
||||||
'temporary_override' => 'Temporary override',
|
|
||||||
'switch_language' => 'Switch language',
|
|
||||||
'clear_override' => 'Use inherited language',
|
|
||||||
'personal_preference' => 'Personal preference',
|
|
||||||
'save_preference' => 'Save preference',
|
|
||||||
'inherit_workspace' => 'Use workspace default',
|
|
||||||
'workspace' => 'Workspace',
|
|
||||||
'choose_workspace' => 'Choose workspace',
|
|
||||||
'switch_workspace' => 'Switch workspace',
|
|
||||||
'workspace_home' => 'Workspace Home',
|
|
||||||
'tenant_scope' => 'Tenant scope',
|
|
||||||
'select_tenant' => 'Select tenant',
|
|
||||||
'selected_tenant' => 'Selected tenant',
|
|
||||||
'no_tenant_selected' => 'No tenant selected',
|
|
||||||
'switch_tenant' => 'Switch tenant',
|
|
||||||
'clear_tenant_scope' => 'Clear tenant scope',
|
|
||||||
'context_unavailable' => 'Context unavailable',
|
|
||||||
'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.',
|
|
||||||
'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.',
|
|
||||||
'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.',
|
|
||||||
'view_managed_tenants' => 'View managed tenants',
|
|
||||||
'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.',
|
|
||||||
'search_tenants' => 'Search tenants...',
|
|
||||||
'choose_workspace_first' => 'Choose a workspace first.',
|
|
||||||
],
|
|
||||||
'workspace' => [
|
|
||||||
'title' => 'Workspace settings',
|
|
||||||
'save' => 'Save',
|
|
||||||
'reset' => 'Reset',
|
|
||||||
'no_manage_permission' => 'You do not have permission to manage workspace settings.',
|
|
||||||
'no_workspace_override' => 'No workspace override to reset.',
|
|
||||||
'last_modified_by' => ':description - Last modified by :user, :time.',
|
|
||||||
'section' => 'Localization settings',
|
|
||||||
'section_description' => 'Workspace default used by users without a personal language preference.',
|
|
||||||
'default_locale_label' => 'Default language',
|
|
||||||
'default_locale_placeholder' => 'Unset (uses system default)',
|
|
||||||
'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).',
|
|
||||||
'default_locale_helper_set' => 'Effective language: :locale.',
|
|
||||||
],
|
|
||||||
'auth' => [
|
|
||||||
'microsoft_not_configured' => 'Microsoft sign-in is not configured.',
|
|
||||||
'sign_in_microsoft' => 'Sign in with Microsoft',
|
|
||||||
'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.',
|
|
||||||
],
|
|
||||||
'navigation' => [
|
|
||||||
'findings' => 'Findings',
|
|
||||||
'settings' => 'Settings',
|
|
||||||
'integrations' => 'Integrations',
|
|
||||||
'manage_workspaces' => 'Manage workspaces',
|
|
||||||
'operations' => 'Operations',
|
|
||||||
'audit_log' => 'Audit Log',
|
|
||||||
'alerts' => 'Alerts',
|
|
||||||
'governance' => 'Governance',
|
|
||||||
'monitoring' => 'Monitoring',
|
|
||||||
'dashboard' => 'Dashboard',
|
|
||||||
],
|
|
||||||
'dashboard' => [
|
|
||||||
'tenant_title' => 'Tenant dashboard',
|
|
||||||
'system_title' => 'System dashboard',
|
|
||||||
'request_support' => 'Request support',
|
|
||||||
'support_request_heading' => 'Request support',
|
|
||||||
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
|
|
||||||
'submit_request' => 'Submit request',
|
|
||||||
'included_context' => 'Included context',
|
|
||||||
'severity' => 'Severity',
|
|
||||||
'summary' => 'Summary',
|
|
||||||
'reproduction_notes' => 'Reproduction notes',
|
|
||||||
'contact_name' => 'Contact name',
|
|
||||||
'contact_email' => 'Contact email',
|
|
||||||
'support_request_submitted' => 'Support request submitted',
|
|
||||||
'open_support_diagnostics' => 'Open support diagnostics',
|
|
||||||
'support_diagnostics' => 'Support diagnostics',
|
|
||||||
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
|
|
||||||
'close' => 'Close',
|
|
||||||
'time_window' => 'Time window',
|
|
||||||
'window' => 'Window',
|
|
||||||
'enter_break_glass' => 'Enter break-glass mode',
|
|
||||||
'exit_break_glass' => 'Exit break-glass',
|
|
||||||
'recovery_mode_enabled' => 'Recovery mode enabled',
|
|
||||||
'recovery_mode_ended' => 'Recovery mode ended',
|
|
||||||
],
|
|
||||||
'review' => [
|
|
||||||
'reporting' => 'Reporting',
|
|
||||||
'customer_reviews' => 'Customer reviews',
|
|
||||||
'customer_review_workspace' => 'Customer Review Workspace',
|
|
||||||
'customer_safe_review_workspace' => 'Customer-safe review workspace',
|
|
||||||
'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.',
|
|
||||||
'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.',
|
|
||||||
'reviews' => 'Reviews',
|
|
||||||
'clear_filters' => 'Clear filters',
|
|
||||||
'tenant' => 'Tenant',
|
|
||||||
'latest_review' => 'Latest review',
|
|
||||||
'key_findings' => 'Key findings',
|
|
||||||
'accepted_risks' => 'Accepted risks',
|
|
||||||
'published' => 'Published',
|
|
||||||
'review_pack' => 'Review pack',
|
|
||||||
'open_latest_review' => 'Open latest review',
|
|
||||||
'download_review_pack' => 'Download review pack',
|
|
||||||
'no_entitled_tenants' => 'No entitled tenants match this view',
|
|
||||||
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
|
|
||||||
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
|
|
||||||
'no_published_review' => 'No published review',
|
|
||||||
'no_published_review_available' => 'No published review available yet',
|
|
||||||
'no_findings_recorded' => 'No findings recorded in the published review.',
|
|
||||||
'findings_count_summary' => ':count findings summarized in the published review.',
|
|
||||||
'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.',
|
|
||||||
'no_accepted_risks_recorded' => 'No accepted risks recorded.',
|
|
||||||
'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).',
|
|
||||||
'accepted_risks_governed' => ':count accepted risks are governed.',
|
|
||||||
'accepted_risks_on_record' => ':count accepted risks are on record.',
|
|
||||||
'unavailable' => 'Unavailable',
|
|
||||||
'available' => 'Available',
|
|
||||||
'outcome_summary' => 'Outcome summary',
|
|
||||||
'review' => 'Review',
|
|
||||||
'review_date' => 'Review date',
|
|
||||||
'completeness' => 'Completeness',
|
|
||||||
'evidence_snapshot' => 'Evidence snapshot',
|
|
||||||
'current_export' => 'Current export',
|
|
||||||
'executive_posture' => 'Executive posture',
|
|
||||||
'sections' => 'Sections',
|
|
||||||
'details' => 'Details',
|
|
||||||
'export_executive_pack' => 'Export executive pack',
|
|
||||||
'outcome' => 'Outcome',
|
|
||||||
'export' => 'Export',
|
|
||||||
'next_step' => 'Next step',
|
|
||||||
'no_tenant_reviews_yet' => 'No tenant reviews yet',
|
|
||||||
'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.',
|
|
||||||
'create_first_review' => 'Create first review',
|
|
||||||
'create_review' => 'Create review',
|
|
||||||
'evidence_basis' => 'Evidence basis',
|
|
||||||
'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.',
|
|
||||||
'unable_create_missing_context' => 'Unable to create review - missing context.',
|
|
||||||
'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.',
|
|
||||||
'unable_create_review' => 'Unable to create review',
|
|
||||||
'review_already_available' => 'Review already available',
|
|
||||||
'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.',
|
|
||||||
'view_review' => 'View review',
|
|
||||||
'open_operation' => 'Open operation',
|
|
||||||
'review_composing_background' => 'The review is being composed in the background.',
|
|
||||||
'unable_export_missing_context' => 'Unable to export review - missing context.',
|
|
||||||
'export_already_queued_body' => 'An executive pack export is already queued or running for this review.',
|
|
||||||
'executive_pack_export_unavailable' => 'Executive pack export unavailable',
|
|
||||||
'unable_export_executive_pack' => 'Unable to export executive pack',
|
|
||||||
'executive_pack_already_available' => 'Executive pack already available',
|
|
||||||
'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.',
|
|
||||||
'view_pack' => 'View pack',
|
|
||||||
'executive_pack_generating_background' => 'The executive pack is being generated in the background.',
|
|
||||||
'review_explanation' => 'Review explanation',
|
|
||||||
'reason_owner' => 'Reason owner',
|
|
||||||
'platform_core' => 'Platform core',
|
|
||||||
'platform_reason_family' => 'Platform reason family',
|
|
||||||
'compatibility' => 'Compatibility',
|
|
||||||
'highlights' => 'Highlights',
|
|
||||||
'next_actions' => 'Next actions',
|
|
||||||
'related_context' => 'Related context',
|
|
||||||
'publication_readiness' => 'Publication readiness',
|
|
||||||
'ready_for_publication' => 'This review is ready for publication and executive-pack export.',
|
|
||||||
'internal_only' => 'This review is currently safe for internal use only.',
|
|
||||||
'needs_follow_up' => 'This review still needs follow-up before publication.',
|
|
||||||
'key_entries' => 'Key entries',
|
|
||||||
'entry' => 'Entry',
|
|
||||||
'follow_up' => 'Follow-up',
|
|
||||||
'diagnostics' => 'Diagnostics',
|
|
||||||
'result_meaning' => 'Result meaning',
|
|
||||||
'result_trust' => 'Result trust',
|
|
||||||
'artifact_truth' => 'Artifact truth',
|
|
||||||
'no_action_needed' => 'No action needed',
|
|
||||||
'count' => 'Count',
|
|
||||||
'guidance' => 'Guidance',
|
|
||||||
'findings' => 'Findings',
|
|
||||||
'reports' => 'Reports',
|
|
||||||
'operations' => 'Operations',
|
|
||||||
'pending_verification' => 'Pending verification',
|
|
||||||
'verified_cleared' => 'Verified cleared',
|
|
||||||
'terminal_outcomes' => 'Terminal outcomes',
|
|
||||||
'pending' => 'Pending',
|
|
||||||
'operation' => 'Operation',
|
|
||||||
'operation_description' => 'Inspect the latest review composition or refresh run.',
|
|
||||||
'executive_pack' => 'Executive pack',
|
|
||||||
'view_executive_pack' => 'View executive pack',
|
|
||||||
'executive_pack_description' => 'Open the current export that belongs to this review.',
|
|
||||||
'customer_workspace' => 'Customer workspace',
|
|
||||||
'open_customer_workspace' => 'Open customer workspace',
|
|
||||||
'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
|
|
||||||
'view_evidence_snapshot' => 'View evidence snapshot',
|
|
||||||
'evidence_snapshot_description' => 'Return to the evidence basis behind this review.',
|
|
||||||
],
|
|
||||||
'findings' => [
|
|
||||||
'all' => 'All',
|
|
||||||
'needs_action' => 'Needs action',
|
|
||||||
'overdue' => 'Overdue',
|
|
||||||
'risk_accepted' => 'Risk accepted',
|
|
||||||
'resolved' => 'Resolved',
|
|
||||||
'actions' => 'Actions',
|
|
||||||
'open_approval_queue' => 'Open approval queue',
|
|
||||||
],
|
|
||||||
'notifications' => [
|
|
||||||
'locale_override_saved' => 'Language override applied.',
|
|
||||||
'locale_override_cleared' => 'Language override cleared.',
|
|
||||||
'user_preference_saved' => 'Language preference saved.',
|
|
||||||
'user_preference_cleared' => 'Language preference cleared.',
|
|
||||||
'workspace_settings_saved' => 'Workspace settings saved',
|
|
||||||
'workspace_settings_unchanged' => 'No settings changes to save',
|
|
||||||
'workspace_setting_reset' => 'Workspace setting reset to default',
|
|
||||||
'setting_already_default' => 'Setting already uses default',
|
|
||||||
],
|
|
||||||
'validation' => [
|
|
||||||
'unsupported_locale' => 'Choose a supported language.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@ -1,10 +1,7 @@
|
|||||||
@php
|
@php
|
||||||
use App\Support\Verification\VerificationLinkBehavior;
|
|
||||||
|
|
||||||
$help = is_array($help ?? null) ? $help : [];
|
$help = is_array($help ?? null) ? $help : [];
|
||||||
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
|
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
|
||||||
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
|
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
|
||||||
$linkBehavior = app(VerificationLinkBehavior::class);
|
|
||||||
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
|
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
|
||||||
? (string) ($help['headline'])
|
? (string) ($help['headline'])
|
||||||
: 'Contextual help';
|
: 'Contextual help';
|
||||||
@ -60,16 +57,9 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@foreach ($links as $link)
|
@foreach ($links as $link)
|
||||||
@php
|
@php
|
||||||
$linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== ''
|
|
||||||
? (string) $link['label']
|
|
||||||
: 'Open';
|
|
||||||
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
|
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
|
||||||
? (string) $link['url']
|
? (string) $link['url']
|
||||||
: null;
|
: null;
|
||||||
$behavior = $linkUrl !== null
|
|
||||||
? $linkBehavior->describe($linkLabel, $linkUrl)
|
|
||||||
: null;
|
|
||||||
$testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel);
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($linkUrl)
|
@if ($linkUrl)
|
||||||
@ -78,11 +68,8 @@
|
|||||||
:href="$linkUrl"
|
:href="$linkUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
:target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null"
|
|
||||||
:rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null"
|
|
||||||
:data-testid="$testId"
|
|
||||||
>
|
>
|
||||||
{{ $linkLabel }}
|
{{ (string) ($link['label'] ?? 'Open') }}
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
$compressedOutcome['primaryLabel'] ?? null,
|
$compressedOutcome['primaryLabel'] ?? null,
|
||||||
$state['primaryLabel'] ?? null,
|
$state['primaryLabel'] ?? null,
|
||||||
$operatorExplanation['headline'] ?? null,
|
$operatorExplanation['headline'] ?? null,
|
||||||
__('localization.review.artifact_truth'),
|
'Artifact truth',
|
||||||
]);
|
]);
|
||||||
$primaryReason = $firstArtifactTruthText([
|
$primaryReason = $firstArtifactTruthText([
|
||||||
$compressedOutcome['primaryReason'] ?? null,
|
$compressedOutcome['primaryReason'] ?? null,
|
||||||
@ -49,7 +49,7 @@
|
|||||||
$compressedOutcome['nextActionText'] ?? null,
|
$compressedOutcome['nextActionText'] ?? null,
|
||||||
data_get($operatorExplanation, 'nextAction.text'),
|
data_get($operatorExplanation, 'nextAction.text'),
|
||||||
$state['nextActionLabel'] ?? null,
|
$state['nextActionLabel'] ?? null,
|
||||||
__('localization.review.no_action_needed'),
|
'No action needed',
|
||||||
]);
|
]);
|
||||||
$diagnosticsSummary = $firstArtifactTruthText([
|
$diagnosticsSummary = $firstArtifactTruthText([
|
||||||
$compressedOutcome['diagnosticsSummary'] ?? null,
|
$compressedOutcome['diagnosticsSummary'] ?? null,
|
||||||
@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
|
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
|
||||||
$summaryFacts->push([
|
$summaryFacts->push([
|
||||||
'label' => __('localization.review.result_meaning'),
|
'label' => 'Result meaning',
|
||||||
'value' => $evaluationSpec->label,
|
'value' => $evaluationSpec->label,
|
||||||
'badge' => BadgeCatalog::summaryData($evaluationSpec),
|
'badge' => BadgeCatalog::summaryData($evaluationSpec),
|
||||||
]);
|
]);
|
||||||
@ -89,7 +89,7 @@
|
|||||||
|
|
||||||
if ($trustSpec && $trustSpec->label !== 'Unknown') {
|
if ($trustSpec && $trustSpec->label !== 'Unknown') {
|
||||||
$summaryFacts->push([
|
$summaryFacts->push([
|
||||||
'label' => __('localization.review.result_trust'),
|
'label' => 'Result trust',
|
||||||
'value' => $trustSpec->label,
|
'value' => $trustSpec->label,
|
||||||
'badge' => BadgeCatalog::summaryData($trustSpec),
|
'badge' => BadgeCatalog::summaryData($trustSpec),
|
||||||
]);
|
]);
|
||||||
@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ __('localization.review.diagnostics') }}
|
Diagnostics
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
{{ $count['label'] ?? __('localization.review.count') }}
|
{{ $count['label'] ?? 'Count' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ (int) ($count['value'] ?? 0) }}
|
{{ (int) ($count['value'] ?? 0) }}
|
||||||
@ -211,7 +211,7 @@
|
|||||||
|
|
||||||
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_step') }}</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
{{ $nextActionText }}
|
{{ $nextActionText }}
|
||||||
</dd>
|
</dd>
|
||||||
@ -237,7 +237,7 @@
|
|||||||
|
|
||||||
@if ($nextSteps !== [])
|
@if ($nextSteps !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.guidance') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
@foreach ($nextSteps as $step)
|
@foreach ($nextSteps as $step)
|
||||||
@continue(! is_string($step) || trim($step) === '')
|
@continue(! is_string($step) || trim($step) === '')
|
||||||
|
|||||||
@ -42,14 +42,14 @@
|
|||||||
|
|
||||||
@if ($entries !== [])
|
@if ($entries !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_entries') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@foreach ($entries as $entry)
|
@foreach ($entries as $entry)
|
||||||
@continue(! is_array($entry))
|
@continue(! is_array($entry))
|
||||||
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
|
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
@if ($nextActions !== [])
|
@if ($nextActions !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.follow_up') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div>
|
||||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
@foreach ($nextActions as $action)
|
@foreach ($nextActions as $action)
|
||||||
@continue(! is_string($action) || trim($action) === '')
|
@continue(! is_string($action) || trim($action) === '')
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
@if ($operatorExplanation !== [])
|
@if ($operatorExplanation !== [])
|
||||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
{{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
|
{{ $operatorExplanation['headline'] ?? 'Review explanation' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (filled($operatorExplanation['reliabilityStatement'] ?? null))
|
@if (filled($operatorExplanation['reliabilityStatement'] ?? null))
|
||||||
@ -45,13 +45,13 @@
|
|||||||
@if ($reasonSemantics !== [])
|
@if ($reasonSemantics !== [])
|
||||||
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.reason_owner') }}</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}</dd>
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.platform_reason_family') }}</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}</dd>
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@endif
|
@endif
|
||||||
@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
@if ($highlights !== [])
|
@if ($highlights !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div>
|
||||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
@foreach ($highlights as $highlight)
|
@foreach ($highlights as $highlight)
|
||||||
@continue(! is_string($highlight) || trim($highlight) === '')
|
@continue(! is_string($highlight) || trim($highlight) === '')
|
||||||
@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
@if ($nextActions !== [])
|
@if ($nextActions !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_actions') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div>
|
||||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
@foreach ($nextActions as $action)
|
@foreach ($nextActions as $action)
|
||||||
@continue(! is_string($action) || trim($action) === '')
|
@continue(! is_string($action) || trim($action) === '')
|
||||||
@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
@if ($contextLinks !== [])
|
@if ($contextLinks !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
|
||||||
<div class="grid gap-3 md:grid-cols-3">
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
@foreach ($contextLinks as $link)
|
@foreach ($contextLinks as $link)
|
||||||
@php
|
@php
|
||||||
@ -130,11 +130,11 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||||
|
|
||||||
@if ($publishBlockers === [] && $decisionDirection === 'publishable')
|
@if ($publishBlockers === [] && $decisionDirection === 'publishable')
|
||||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||||
{{ __('localization.review.ready_for_publication') }}
|
This review is ready for publication and executive-pack export.
|
||||||
</div>
|
</div>
|
||||||
@elseif ($publishBlockers !== [])
|
@elseif ($publishBlockers !== [])
|
||||||
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
||||||
@ -146,7 +146,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
@elseif ($decisionDirection === 'internal_only')
|
@elseif ($decisionDirection === 'internal_only')
|
||||||
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
<div>{{ __('localization.review.internal_only') }}</div>
|
<div>This review is currently safe for internal use only.</div>
|
||||||
|
|
||||||
@if ($publicationNextAction !== null)
|
@if ($publicationNextAction !== null)
|
||||||
<div class="mt-1">{{ $publicationNextAction }}</div>
|
<div class="mt-1">{{ $publicationNextAction }}</div>
|
||||||
@ -154,7 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
{{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
|
{{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
@if (! $isConfigured)
|
@if (! $isConfigured)
|
||||||
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
{{ __('localization.auth.microsoft_not_configured') }}
|
Microsoft sign-in is not configured.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@ -25,11 +25,11 @@
|
|||||||
:disabled="! $isConfigured"
|
:disabled="! $isConfigured"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{{ __('localization.auth.sign_in_microsoft') }}
|
Sign in with Microsoft
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
|
||||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
|
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
{{ __('localization.auth.tenant_admin_membership_required') }}
|
Tenant Admin access requires a tenant membership.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ __('localization.review.customer_safe_review_workspace') }}
|
Customer-safe review workspace
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ __('localization.review.customer_workspace_intro') }}
|
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ __('localization.review.customer_workspace_canonical_note') }}
|
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -31,8 +31,8 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected');
|
$tenantLabel = $currentTenantName ?? 'No tenant selected';
|
||||||
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
|
$workspaceLabel = $workspace?->name ?? 'Choose workspace';
|
||||||
$hasActiveTenant = $currentTenantName !== null;
|
$hasActiveTenant = $currentTenantName !== null;
|
||||||
$managedTenantsUrl = $workspace
|
$managedTenantsUrl = $workspace
|
||||||
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
||||||
@ -40,8 +40,7 @@
|
|||||||
$workspaceUrl = $workspace
|
$workspaceUrl = $workspace
|
||||||
? route('admin.home')
|
? route('admin.home')
|
||||||
: ChooseWorkspace::getUrl(panel: 'admin');
|
: ChooseWorkspace::getUrl(panel: 'admin');
|
||||||
$tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace');
|
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
|
||||||
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
|
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
|
||||||
@ -64,7 +63,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
|
|||||||
<x-slot name="trigger">
|
<x-slot name="trigger">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}"
|
aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}"
|
||||||
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
|
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
|
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||||
@ -79,12 +78,12 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
|||||||
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
|
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
|
||||||
@if ($resolvedContext->showsRecoveryNotice())
|
@if ($resolvedContext->showsRecoveryNotice())
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||||
<div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
|
<div class="font-semibold">Context unavailable</div>
|
||||||
|
|
||||||
@if ($workspace)
|
@if ($workspace)
|
||||||
<div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
|
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
|
||||||
@else
|
@else
|
||||||
<div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
|
<div>Choose a workspace to continue with a valid admin context.</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -92,7 +91,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
|||||||
{{-- Workspace section --}}
|
{{-- Workspace section --}}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
{{ __('localization.shell.workspace') }}
|
Workspace
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
||||||
@ -105,7 +104,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
|||||||
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
|
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
|
||||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
>
|
>
|
||||||
{{ __('localization.shell.switch_workspace') }}
|
Switch workspace
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -114,7 +113,7 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-pri
|
|||||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
|
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||||
{{ __('localization.shell.workspace_home') }}
|
Workspace Home
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -125,7 +124,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
{{ __('localization.shell.selected_tenant') }}
|
Selected tenant
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -138,7 +137,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
|||||||
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
||||||
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
>
|
>
|
||||||
{{ __('localization.shell.switch_tenant') }}
|
Switch tenant
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -147,7 +146,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||||
{{ __('localization.shell.clear_tenant_scope') }}
|
Clear tenant scope
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
@ -155,23 +154,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
|||||||
@else
|
@else
|
||||||
@if ($tenants->isEmpty())
|
@if ($tenants->isEmpty())
|
||||||
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
||||||
<div>{{ __('localization.shell.no_active_tenants') }}</div>
|
<div>No active tenants are available for the standard operating context in this workspace.</div>
|
||||||
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
|
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
|
||||||
{{ __('localization.shell.view_managed_tenants') }}
|
View managed tenants
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@if (! $hasActiveTenant)
|
@if (! $hasActiveTenant)
|
||||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
|
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
|
||||||
{{ __('localization.shell.workspace_wide_available') }}
|
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="fi-input fi-text-input w-full"
|
class="fi-input fi-text-input w-full"
|
||||||
placeholder="{{ __('localization.shell.search_tenants') }}"
|
placeholder="Search tenants…"
|
||||||
x-model="query"
|
x-model="query"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -208,7 +207,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||||
{{ __('localization.shell.clear_tenant_scope') }}
|
Clear tenant scope
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
@ -217,12 +216,10 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
|||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
||||||
{{ __('localization.shell.choose_workspace_first') }}
|
Choose a workspace first.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::dropdown.list>
|
</x-filament::dropdown.list>
|
||||||
</x-filament::dropdown>
|
</x-filament::dropdown>
|
||||||
|
|
||||||
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
@php
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
|
|
||||||
$plane = $plane ?? 'admin';
|
|
||||||
$showPreference = (bool) ($showPreference ?? true);
|
|
||||||
$embedded = (bool) ($embedded ?? false);
|
|
||||||
|
|
||||||
/** @var LocaleResolver $localeResolver */
|
|
||||||
$localeResolver = app(LocaleResolver::class);
|
|
||||||
$localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
|
|
||||||
$localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane);
|
|
||||||
$localeOptions = LocaleResolver::localeOptions();
|
|
||||||
$currentLocale = (string) ($localeContext['locale'] ?? 'en');
|
|
||||||
$source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT);
|
|
||||||
$sourceLabel = __('localization.source.'.$source);
|
|
||||||
$user = auth()->user();
|
|
||||||
$preferredLocale = $user instanceof User ? $user->preferred_locale : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="{{ $embedded ? 'border-l border-gray-200 dark:border-white/10' : 'inline-flex rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5' }}">
|
|
||||||
<x-filament::dropdown placement="bottom-end" teleport width="sm">
|
|
||||||
<x-slot name="trigger">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="{{ __('localization.shell.language') }}"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 text-sm transition hover:bg-gray-50 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<x-filament::icon icon="heroicon-o-language" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">{{ strtoupper($currentLocale) }}</span>
|
|
||||||
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-filament::dropdown.list>
|
|
||||||
<div class="space-y-3 px-3 py-2">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
||||||
{{ __('localization.shell.current_language') }}
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
|
||||||
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
|
||||||
{{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ __('localization.shell.language_source', ['source' => $sourceLabel]) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-white/10"></div>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('localization.override.update') }}" class="space-y-2">
|
|
||||||
@csrf
|
|
||||||
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-override-{{ $plane }}">
|
|
||||||
{{ __('localization.shell.temporary_override') }}
|
|
||||||
</label>
|
|
||||||
<x-filament::input.wrapper class="w-full">
|
|
||||||
<x-filament::input.select
|
|
||||||
id="tenantpilot-locale-override-{{ $plane }}"
|
|
||||||
name="locale"
|
|
||||||
>
|
|
||||||
@foreach ($localeOptions as $locale => $label)
|
|
||||||
<option value="{{ $locale }}" @selected($currentLocale === $locale)>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</x-filament::input.select>
|
|
||||||
</x-filament::input.wrapper>
|
|
||||||
<button type="submit" class="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-primary-500">
|
|
||||||
{{ __('localization.shell.switch_language') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE)
|
|
||||||
<form method="POST" action="{{ route('localization.override.clear') }}">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
|
||||||
{{ __('localization.shell.clear_override') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($showPreference && $user instanceof User)
|
|
||||||
<div class="border-t border-gray-200 dark:border-white/10"></div>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('localization.preference.update') }}" class="space-y-2">
|
|
||||||
@csrf
|
|
||||||
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-preference-{{ $plane }}">
|
|
||||||
{{ __('localization.shell.personal_preference') }}
|
|
||||||
</label>
|
|
||||||
<x-filament::input.wrapper class="w-full">
|
|
||||||
<x-filament::input.select
|
|
||||||
id="tenantpilot-locale-preference-{{ $plane }}"
|
|
||||||
name="preferred_locale"
|
|
||||||
>
|
|
||||||
<option value="" @selected($preferredLocale === null)>{{ __('localization.shell.inherit_workspace') }}</option>
|
|
||||||
@foreach ($localeOptions as $locale => $label)
|
|
||||||
<option value="{{ $locale }}" @selected($preferredLocale === $locale)>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</x-filament::input.select>
|
|
||||||
</x-filament::input.wrapper>
|
|
||||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-sm font-medium text-primary-600 transition hover:bg-primary-50 hover:text-primary-500 dark:text-primary-400 dark:hover:bg-primary-500/10 dark:hover:text-primary-300">
|
|
||||||
{{ __('localization.shell.save_preference') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::dropdown.list>
|
|
||||||
</x-filament::dropdown>
|
|
||||||
</div>
|
|
||||||
@ -1,3 +1,13 @@
|
|||||||
|
@php
|
||||||
|
$findingsLastRun = $this->findingsLastRun();
|
||||||
|
$findingsLastRunStatusSpec = $findingsLastRun
|
||||||
|
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
|
||||||
|
: null;
|
||||||
|
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
|
||||||
|
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
@ -7,7 +17,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
||||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
|
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -15,17 +25,100 @@
|
|||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
No supported runbooks
|
Rebuild Findings Lifecycle
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="description">
|
<x-slot name="description">
|
||||||
Supported platform runbooks will appear here when they are part of current product truth.
|
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
<x-slot name="afterHeader">
|
||||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
<x-filament::badge color="info" size="sm">
|
||||||
There are no operator-run repair runbooks exposed on this surface.
|
{{ $this->findingsScopeLabel() }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@if ($findingsLastRun)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if ($findingsLastRunStatusSpec)
|
||||||
|
<x-filament::badge
|
||||||
|
:color="$findingsLastRunStatusSpec->color"
|
||||||
|
:icon="$findingsLastRunStatusSpec->icon"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $findingsLastRunStatusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($findingsLastRunOutcomeSpec)
|
||||||
|
<x-filament::badge
|
||||||
|
:color="$findingsLastRunOutcomeSpec->color"
|
||||||
|
:icon="$findingsLastRunOutcomeSpec->icon"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $findingsLastRunOutcomeSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($findingsLastRun->initiator_name)
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
by {{ $findingsLastRun->initiator_name }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($this->findingsPreflight))
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||||
|
Nothing to do for the current scope.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
|
||||||
|
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
use App\Http\Controllers\ClearTenantContextController;
|
use App\Http\Controllers\ClearTenantContextController;
|
||||||
use App\Http\Controllers\LocalizationController;
|
|
||||||
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Http\Controllers\ReviewPackDownloadController;
|
use App\Http\Controllers\ReviewPackDownloadController;
|
||||||
@ -68,21 +67,6 @@
|
|||||||
->middleware('throttle:entra-callback')
|
->middleware('throttle:entra-callback')
|
||||||
->name('auth.entra.callback');
|
->name('auth.entra.callback');
|
||||||
|
|
||||||
Route::middleware(['web'])->group(function (): void {
|
|
||||||
Route::get('/localization/context', [LocalizationController::class, 'context'])
|
|
||||||
->name('localization.context');
|
|
||||||
|
|
||||||
Route::post('/localization/override', [LocalizationController::class, 'updateOverride'])
|
|
||||||
->name('localization.override.update');
|
|
||||||
|
|
||||||
Route::delete('/localization/override', [LocalizationController::class, 'clearOverride'])
|
|
||||||
->name('localization.override.clear');
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
|
||||||
->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference'])
|
|
||||||
->name('localization.preference.update');
|
|
||||||
|
|
||||||
$makeSmokeCookie = static fn () => cookie()->make(
|
$makeSmokeCookie = static fn () => cookie()->make(
|
||||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||||
|
|||||||
@ -61,6 +61,18 @@
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$visibleSelectValue = <<<'JS'
|
||||||
|
(() => {
|
||||||
|
const select = [...document.querySelectorAll('select')].find((element) => {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||||
|
});
|
||||||
|
|
||||||
|
return select?.value ?? null;
|
||||||
|
})()
|
||||||
|
JS;
|
||||||
|
|
||||||
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
||||||
|
|
||||||
$page
|
$page
|
||||||
@ -75,8 +87,8 @@
|
|||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertSee('Verify access')
|
->assertSee('Verify access')
|
||||||
->assertSee('Status: Not started')
|
->assertSee('Status: Not started')
|
||||||
->click('Select an existing connection or create a new one.')
|
->click('Provider connection')
|
||||||
->assertSee('Edit selected connection')
|
->assertScript($visibleSelectValue, (string) $connection->getKey())
|
||||||
->click('Create new connection')
|
->click('Create new connection')
|
||||||
->check('internal:label="Dedicated override"s')
|
->check('internal:label="Dedicated override"s')
|
||||||
->fill('[type="password"]', 'browser-only-secret')
|
->fill('[type="password"]', 'browser-only-secret')
|
||||||
@ -85,8 +97,8 @@
|
|||||||
->waitForText('Status: Not started')
|
->waitForText('Status: Not started')
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertSee('Verify access')
|
->assertSee('Verify access')
|
||||||
->click('Select an existing connection or create a new one.')
|
->click('Provider connection')
|
||||||
->assertSee('Edit selected connection')
|
->assertScript($visibleSelectValue, (string) $connection->getKey())
|
||||||
->click('Create new connection')
|
->click('Create new connection')
|
||||||
->check('internal:label="Dedicated override"s')
|
->check('internal:label="Dedicated override"s')
|
||||||
->assertValue('[type="password"]', '');
|
->assertValue('[type="password"]', '');
|
||||||
|
|||||||
@ -86,6 +86,18 @@
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$visibleSelectValue = <<<'JS'
|
||||||
|
(() => {
|
||||||
|
const select = [...document.querySelectorAll('select')].find((element) => {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||||
|
});
|
||||||
|
|
||||||
|
return select?.value ?? null;
|
||||||
|
})()
|
||||||
|
JS;
|
||||||
|
|
||||||
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
||||||
|
|
||||||
$page
|
$page
|
||||||
@ -101,8 +113,8 @@
|
|||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertSee('Status: Needs attention')
|
->assertSee('Status: Needs attention')
|
||||||
->assertSee('Start verification')
|
->assertSee('Start verification')
|
||||||
->click('Select an existing connection or create a new one.')
|
->click('Provider connection')
|
||||||
->assertSee('Edit selected connection');
|
->assertScript($visibleSelectValue, (string) $selectedConnection->getKey());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
|
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
|
||||||
@ -316,14 +328,32 @@
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->wait(1)
|
->wait(1)
|
||||||
->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true)
|
||||||
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank')
|
->click('[data-testid="verification-assist-trigger"]')
|
||||||
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer')
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
|
||||||
->click('[data-testid="contextual-help-link-open-required-permissions"]')
|
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank');
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
writeText: async () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('[data-testid="verification-assist-copy-application"]')?.click();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Copied')
|
||||||
|
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
|
||||||
|
->click('[data-testid="verification-assist-full-page"]')
|
||||||
->wait(1)
|
->wait(1)
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->click('Select an existing connection or create a new one.')
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
|
||||||
->assertSee('Edit selected connection');
|
->click('Close')
|
||||||
|
->click('Provider connection')
|
||||||
|
->assertSee('Select an existing connection or create a new one.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {
|
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\TenantRole;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('removes the acknowledged findings capability alias from shared RBAC truth', function (): void {
|
|
||||||
expect(Capabilities::isKnown('tenant_findings.acknowledge'))->toBeFalse();
|
|
||||||
expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the canonical findings triage capability available to operators', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
|
||||||
|
|
||||||
expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue();
|
|
||||||
expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
|
it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -36,7 +35,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
|
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -74,7 +73,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('Baseline capture (full content) captures evidence on demand when missing', function () {
|
it('Baseline capture (full content) captures evidence on demand when missing', function () {
|
||||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||||
@ -120,7 +119,7 @@ public function capture(
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
|
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -105,7 +104,7 @@
|
|||||||
$operationRuns = app(OperationRunService::class);
|
$operationRuns = app(OperationRunService::class);
|
||||||
$run = $operationRuns->ensureRunWithIdentity(
|
$run = $operationRuns->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
function createBaselineCaptureInventoryBasis(
|
function createBaselineCaptureInventoryBasis(
|
||||||
@ -66,7 +65,7 @@ function runBaselineCaptureJob(
|
|||||||
|
|
||||||
/** @var OperationRun $run */
|
/** @var OperationRun $run */
|
||||||
$run = $result['run'];
|
$run = $result['run'];
|
||||||
expect($run->type)->toBe(OperationRunType::BaselineCapture->value);
|
expect($run->type)->toBe('baseline_capture');
|
||||||
expect($run->status)->toBe('queued');
|
expect($run->status)->toBe('queued');
|
||||||
expect($run->tenant_id)->toBe((int) $tenant->getKey());
|
expect($run->tenant_id)->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
@ -105,7 +104,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture when the latest inventory sync was blocked', function () {
|
it('rejects capture when the latest inventory sync was blocked', function () {
|
||||||
@ -136,7 +135,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
|
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
|
||||||
@ -167,7 +166,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
|
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
|
||||||
@ -190,7 +189,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture for a draft profile with reason code', function () {
|
it('rejects capture for a draft profile with reason code', function () {
|
||||||
@ -210,7 +209,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture for an archived profile with reason code', function () {
|
it('rejects capture for an archived profile with reason code', function () {
|
||||||
@ -229,7 +228,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects capture for a tenant from a different workspace', function () {
|
it('rejects capture for a tenant from a different workspace', function () {
|
||||||
@ -275,7 +274,7 @@ function runBaselineCaptureJob(
|
|||||||
expect($result2['ok'])->toBeTrue();
|
expect($result2['ok'])->toBeTrue();
|
||||||
|
|
||||||
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Snapshot dedupe + capture job execution ---
|
// --- Snapshot dedupe + capture job execution ---
|
||||||
@ -322,7 +321,7 @@ function runBaselineCaptureJob(
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -477,7 +476,7 @@ function runBaselineCaptureJob(
|
|||||||
|
|
||||||
$run1 = $opService->ensureRunWithIdentity(
|
$run1 = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -500,7 +499,7 @@ function runBaselineCaptureJob(
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'user_id' => (int) $user->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
'initiator_name' => $user->name,
|
'initiator_name' => $user->name,
|
||||||
'type' => OperationRunType::BaselineCapture->value,
|
'type' => 'baseline_capture',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
|
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
|
||||||
@ -587,7 +586,7 @@ function runBaselineCaptureJob(
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -663,7 +662,7 @@ function runBaselineCaptureJob(
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -70,14 +69,14 @@
|
|||||||
|
|
||||||
$activeRuns = OperationRun::query()
|
$activeRuns = OperationRun::query()
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
->where('type', 'baseline_compare')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
expect($activeRuns)->toHaveCount(2)
|
expect($activeRuns)->toHaveCount(2)
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
|
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
|
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
|
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
|
||||||
->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
|
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
|
||||||
@ -98,7 +97,7 @@
|
|||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
->where('type', 'baseline_compare')
|
||||||
->whereNull('tenant_id')
|
->whereNull('tenant_id')
|
||||||
->count())->toBe(0);
|
->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('does not register findings lifecycle backfill or deploy-runbook commands', function (): void {
|
|
||||||
$commands = array_keys(Artisan::all());
|
|
||||||
|
|
||||||
expect($commands)
|
|
||||||
->not->toContain('tenantpilot:findings:backfill-lifecycle')
|
|
||||||
->not->toContain('tenantpilot:run-deploy-runbooks');
|
|
||||||
|
|
||||||
expect((string) file_get_contents(base_path('routes/console.php')))
|
|
||||||
->not->toContain('tenantpilot:findings:backfill-lifecycle')
|
|
||||||
->not->toContain('tenantpilot:run-deploy-runbooks');
|
|
||||||
});
|
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Services\Runbooks\RunbookReason;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('delegates deploy-time runbooks to the shared runbook service', function () {
|
||||||
|
$run = OperationRun::factory()->create();
|
||||||
|
|
||||||
|
$this->mock(FindingsLifecycleBackfillRunbookService::class, function ($mock) use ($run): void {
|
||||||
|
$mock->shouldReceive('start')
|
||||||
|
->once()
|
||||||
|
->withArgs(function ($scope, $initiator, $reason, $source): bool {
|
||||||
|
return $scope instanceof FindingsLifecycleBackfillScope
|
||||||
|
&& $scope->isAllTenants()
|
||||||
|
&& $initiator === null
|
||||||
|
&& $reason instanceof RunbookReason
|
||||||
|
&& $source === 'deploy_hook';
|
||||||
|
})
|
||||||
|
->andReturn($run);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('tenantpilot:run-deploy-runbooks')
|
||||||
|
->assertExitCode(0);
|
||||||
|
});
|
||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Jobs\EntraGroupSyncJob;
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
use App\Services\Directory\EntraGroupSyncService;
|
use App\Services\Directory\EntraGroupSyncService;
|
||||||
use App\Services\Providers\ProviderOperationStartResult;
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
||||||
@ -22,7 +21,7 @@
|
|||||||
expect($run)
|
expect($run)
|
||||||
->and($run->tenant_id)->toBe($tenant->getKey())
|
->and($run->tenant_id)->toBe($tenant->getKey())
|
||||||
->and($run->user_id)->toBe($user->getKey())
|
->and($run->user_id)->toBe($user->getKey())
|
||||||
->and($run->type)->toBe(OperationRunType::DirectoryGroupsSync->value)
|
->and($run->type)->toBe('entra_group_sync')
|
||||||
->and($run->status)->toBe('queued')
|
->and($run->status)->toBe('queued')
|
||||||
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
|
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
|
||||||
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
|
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('sync job upserts groups and updates run counters', function () {
|
it('sync job upserts groups and updates run counters', function () {
|
||||||
|
|
||||||
@ -55,7 +54,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::DirectoryGroupsSync->value,
|
type: 'entra_group_sync',
|
||||||
inputs: ['selection_key' => 'groups-v1:all'],
|
inputs: ['selection_key' => 'groups-v1:all'],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
|
||||||
it('purges cached groups older than the retention window', function () {
|
it('purges cached groups older than the retention window', function () {
|
||||||
@ -35,7 +34,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::DirectoryGroupsSync->value,
|
type: 'entra_group_sync',
|
||||||
inputs: ['selection_key' => 'groups-v1:all'],
|
inputs: ['selection_key' => 'groups-v1:all'],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -444,7 +444,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
->and($finding->subject_external_id)->toBe('user-1:def-ga');
|
->and($finding->subject_external_id)->toBe('user-1:def-ga');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-resolve applies to triaged findings too', function (): void {
|
it('auto-resolve applies to acknowledged findings too', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createMinimalUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
@ -456,19 +456,20 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
);
|
);
|
||||||
$generator->generate($tenant, $payload);
|
$generator->generate($tenant, $payload);
|
||||||
|
|
||||||
// Triage the finding
|
// Acknowledge the finding
|
||||||
$finding = Finding::query()
|
$finding = Finding::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('subject_external_id', 'user-1:def-ga')
|
->where('subject_external_id', 'user-1:def-ga')
|
||||||
->first();
|
->first();
|
||||||
$finding->forceFill([
|
$finding->forceFill([
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
'triaged_at' => now(),
|
'acknowledged_at' => now(),
|
||||||
|
'acknowledged_by_user_id' => $user->getKey(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED);
|
expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
|
||||||
// Scan 2: remove -> should auto-resolve even though triaged
|
// Scan 2: remove → should auto-resolve even though acknowledged
|
||||||
$payload2 = buildPayload([gaRoleDef()], []);
|
$payload2 = buildPayload([gaRoleDef()], []);
|
||||||
$result = $generator->generate($tenant, $payload2);
|
$result = $generator->generate($tenant, $payload2);
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@
|
|||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
->where('type', 'baseline_compare')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -192,7 +192,7 @@
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
|
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
|
||||||
@ -250,7 +250,7 @@
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can refresh stats without calling mount directly', function (): void {
|
it('can refresh stats without calling mount directly', function (): void {
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -122,7 +121,7 @@ function seedCaptureProfileForTenant(
|
|||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', OperationRunType::BaselineCapture->value)
|
->where('type', 'baseline_capture')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -152,7 +151,7 @@ function seedCaptureProfileForTenant(
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not start full-content capture when rollout is disabled', function (): void {
|
it('does not start full-content capture when rollout is disabled', function (): void {
|
||||||
@ -175,7 +174,7 @@ function seedCaptureProfileForTenant(
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
|
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
|
||||||
@ -229,5 +228,5 @@ function seedCaptureProfileForTenant(
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -93,7 +92,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
|
|||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', OperationRunType::BaselineCompare->value)
|
->where('type', 'baseline_compare')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -121,7 +120,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
|
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
|
||||||
@ -168,7 +167,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||||
@ -276,5 +275,5 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
|
|
||||||
it('resolves first-wave governance labels from the active locale', function (): void {
|
|
||||||
App::setLocale('de');
|
|
||||||
|
|
||||||
expect(__('localization.dashboard.tenant_title'))->toBe('Tenant-Dashboard')
|
|
||||||
->and(FindingResource::getNavigationGroup())->toBe('Governance')
|
|
||||||
->and(__('localization.findings.needs_action'))->toBe('Handlungsbedarf')
|
|
||||||
->and(__('baseline-compare.stat_total_findings'))->toBe('Findings gesamt')
|
|
||||||
->and(__('findings.rbac.detail_heading'))->toBe('Intune-RBAC-Rollendefinitions-Drift');
|
|
||||||
});
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('exposes the findings lifecycle backfill action for entitled tenant operators', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertActionExists('backfill_lifecycle')
|
||||||
|
->assertActionEnabled('backfill_lifecycle');
|
||||||
|
});
|
||||||
@ -34,6 +34,7 @@ protected function makeFindingForWorkflow(Tenant $tenant, string $status = Findi
|
|||||||
$factory = Finding::factory()->for($tenant);
|
$factory = Finding::factory()->for($tenant);
|
||||||
|
|
||||||
$factory = match ($status) {
|
$factory = match ($status) {
|
||||||
|
Finding::STATUS_ACKNOWLEDGED => $factory->acknowledged(),
|
||||||
Finding::STATUS_TRIAGED => $factory->triaged(),
|
Finding::STATUS_TRIAGED => $factory->triaged(),
|
||||||
Finding::STATUS_IN_PROGRESS => $factory->inProgress(),
|
Finding::STATUS_IN_PROGRESS => $factory->inProgress(),
|
||||||
Finding::STATUS_REOPENED => $factory->reopened(),
|
Finding::STATUS_REOPENED => $factory->reopened(),
|
||||||
|
|||||||
136
apps/platform/tests/Feature/Findings/FindingBackfillTest.php
Normal file
136
apps/platform/tests/Feature/Findings/FindingBackfillTest.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('backfills legacy findings (ack → triaged, lifecycle fields, due_at from backfill time)', function (): void {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||||
|
'acknowledged_by_user_id' => (int) $user->getKey(),
|
||||||
|
'first_seen_at' => null,
|
||||||
|
'last_seen_at' => null,
|
||||||
|
'times_seen' => null,
|
||||||
|
'sla_days' => null,
|
||||||
|
'due_at' => null,
|
||||||
|
'triaged_at' => null,
|
||||||
|
'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||||
|
'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackfillFindingLifecycleJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
|
||||||
|
->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||||
|
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||||
|
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||||
|
->and($finding->times_seen)->toBe(1)
|
||||||
|
->and($finding->sla_days)->toBe(14)
|
||||||
|
->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00')
|
||||||
|
->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||||
|
->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes drift recurrence keys and consolidates drift duplicates', function (): void {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-drift-backfill-duplicate');
|
||||||
|
|
||||||
|
$evidence = [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_snapshot',
|
||||||
|
'changed_fields' => ['snapshot_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => ['policy_id' => 'policy-dupe'],
|
||||||
|
'current' => ['policy_id' => 'policy-dupe'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$open = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'policy-dupe',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'recurrence_key' => null,
|
||||||
|
'evidence_jsonb' => $evidence,
|
||||||
|
'first_seen_at' => null,
|
||||||
|
'last_seen_at' => null,
|
||||||
|
'times_seen' => null,
|
||||||
|
'sla_days' => null,
|
||||||
|
'due_at' => null,
|
||||||
|
'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||||
|
'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'policy-dupe',
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
|
||||||
|
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||||
|
'recurrence_key' => null,
|
||||||
|
'evidence_jsonb' => $evidence,
|
||||||
|
'first_seen_at' => null,
|
||||||
|
'last_seen_at' => null,
|
||||||
|
'times_seen' => null,
|
||||||
|
'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
|
||||||
|
'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackfillFindingLifecycleJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$expectedRecurrenceKey = hash(
|
||||||
|
'sha256',
|
||||||
|
sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('recurrence_key', $expectedRecurrenceKey)
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
$open->refresh();
|
||||||
|
$duplicate->refresh();
|
||||||
|
|
||||||
|
expect($open->recurrence_key)->toBe($expectedRecurrenceKey)
|
||||||
|
->and($open->status)->toBe(Finding::STATUS_NEW);
|
||||||
|
|
||||||
|
expect($duplicate->recurrence_key)->toBeNull()
|
||||||
|
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
|
||||||
|
->and($duplicate->resolved_reason)->toBeNull()
|
||||||
|
->and($duplicate->resolved_at)->toBeNull()
|
||||||
|
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||||
|
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
@ -22,8 +22,9 @@
|
|||||||
->toContain(AuditActionId::FindingReopened->value);
|
->toContain(AuditActionId::FindingReopened->value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps only the surviving model lifecycle helpers', function (): void {
|
it('keeps only legacy compatibility lifecycle helpers on the model', function (): void {
|
||||||
expect(method_exists(Finding::class, 'resolve'))->toBeTrue()
|
expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue()
|
||||||
|
->and(method_exists(Finding::class, 'resolve'))->toBeTrue()
|
||||||
->and(method_exists(Finding::class, 'reopen'))->toBeTrue()
|
->and(method_exists(Finding::class, 'reopen'))->toBeTrue()
|
||||||
->and(method_exists(Finding::class, 'triage'))->toBeFalse()
|
->and(method_exists(Finding::class, 'triage'))->toBeFalse()
|
||||||
->and(method_exists(Finding::class, 'startProgress'))->toBeFalse()
|
->and(method_exists(Finding::class, 'startProgress'))->toBeFalse()
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('keeps canonical findings workflow behavior after removing the backfill runtime surfaces', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create();
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$service = app(FindingWorkflowService::class);
|
|
||||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [
|
|
||||||
'owner_user_id' => null,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'sla_days' => 14,
|
|
||||||
'due_at' => now()->addDays(14),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$triaged = $service->triage($finding, $tenant, $owner);
|
|
||||||
$assigned = $service->assign(
|
|
||||||
finding: $triaged,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $assignee->getKey(),
|
|
||||||
ownerUserId: (int) $owner->getKey(),
|
|
||||||
);
|
|
||||||
$inProgress = $service->startProgress($assigned, $tenant, $owner);
|
|
||||||
$resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED);
|
|
||||||
$riskAccepted = $service->riskAccept(
|
|
||||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
|
||||||
$tenant,
|
|
||||||
$owner,
|
|
||||||
Finding::CLOSE_REASON_ACCEPTED_RISK,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($triaged->status)->toBe(Finding::STATUS_TRIAGED)
|
|
||||||
->and($triaged->triaged_at)->not->toBeNull()
|
|
||||||
->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey())
|
|
||||||
->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey())
|
|
||||||
->and($assigned->sla_days)->toBe(14)
|
|
||||||
->and($assigned->due_at)->not->toBeNull()
|
|
||||||
->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS)
|
|
||||||
->and($inProgress->in_progress_at)->not->toBeNull()
|
|
||||||
->and($resolved->status)->toBe(Finding::STATUS_RESOLVED)
|
|
||||||
->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
|
||||||
->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
|
|
||||||
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK);
|
|
||||||
|
|
||||||
expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull()
|
|
||||||
->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull()
|
|
||||||
->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull()
|
|
||||||
->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull()
|
|
||||||
->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull();
|
|
||||||
});
|
|
||||||
@ -101,10 +101,8 @@ function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
|
|||||||
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$acknowledged = Finding::factory()->for($tenantA)->create([
|
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
'status' => 'acknowledged',
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'assignee_user_id' => null,
|
'assignee_user_id' => null,
|
||||||
'subject_external_id' => 'acknowledged',
|
'subject_external_id' => 'acknowledged',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('keeps the findings backfill action visible but blocks execution when a control is active', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'due_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationalControlActivation::factory()->workspaceScoped()->create([
|
||||||
|
'control_key' => 'findings.lifecycle.backfill',
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'reason_text' => 'Workspace-specific pause.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertActionExists('backfill_lifecycle')
|
||||||
|
->assertActionEnabled('backfill_lifecycle')
|
||||||
|
->callAction('backfill_lifecycle')
|
||||||
|
->assertNotified('Findings lifecycle backfill paused');
|
||||||
|
|
||||||
|
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($audit?->status)->toBe('blocked')
|
||||||
|
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
|
||||||
|
->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
[$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $allowedTenant->getKey(),
|
||||||
|
'due_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationalControlActivation::factory()->workspaceScoped()->create([
|
||||||
|
'control_key' => 'findings.lifecycle.backfill',
|
||||||
|
'workspace_id' => (int) $blockedTenant->workspace_id,
|
||||||
|
'reason_text' => 'Paused only for the blocked workspace.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($allowedUser);
|
||||||
|
Filament::setTenant($allowedTenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertActionExists('backfill_lifecycle')
|
||||||
|
->assertActionEnabled('backfill_lifecycle')
|
||||||
|
->callAction('backfill_lifecycle');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('type', 'findings.lifecycle.backfill')
|
||||||
|
->where('tenant_id', (int) $allowedTenant->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
|
||||||
|
Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool {
|
||||||
|
return $job->tenantId === (int) $allowedTenant->getKey()
|
||||||
|
&& $job->workspaceId === (int) $allowedTenant->workspace_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(AuditLog::query()
|
||||||
|
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
|
||||||
|
->where('tenant_id', (int) $allowedTenant->getKey())
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('rejects triage from the removed acknowledged status', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'status' => 'acknowledged',
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(fn () => app(FindingWorkflowService::class)->triage($finding, $tenant, $user))
|
|
||||||
->toThrow(\InvalidArgumentException::class, 'Finding cannot be triaged from the current status.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects start progress from the removed acknowledged status', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'status' => 'acknowledged',
|
|
||||||
'triaged_at' => now()->subMinute(),
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(fn () => app(FindingWorkflowService::class)->startProgress($finding, $tenant, $user))
|
|
||||||
->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
|
|
||||||
});
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('removes the tenant findings lifecycle backfill header action without removing canonical workflow actions', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::test(ListFindings::class)
|
|
||||||
->assertActionDoesNotExist('backfill_lifecycle')
|
|
||||||
->assertActionExists('triage_all_matching')
|
|
||||||
->assertTableActionVisible('triage', $finding)
|
|
||||||
->assertTableActionVisible('assign', $finding)
|
|
||||||
->assertTableActionVisible('resolve', $finding)
|
|
||||||
->assertTableActionVisible('request_exception', $finding);
|
|
||||||
|
|
||||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
@ -12,6 +12,7 @@ function livewireTrustedStateFirstSliceFixtures(): array
|
|||||||
return [
|
return [
|
||||||
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
||||||
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
|
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
|
||||||
|
TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
|
|
||||||
it('renders the admin auth surface in the explicit locale override', function (): void {
|
|
||||||
$this->withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de'])
|
|
||||||
->get('/admin/login')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Mit Microsoft anmelden')
|
|
||||||
->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps system plane resolution independent from user and workspace preferences', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
$user->forceFill(['preferred_locale' => 'de'])->save();
|
|
||||||
session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => null,
|
|
||||||
])
|
|
||||||
->getJson('/localization/context?plane=system')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'en')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT)
|
|
||||||
->assertJsonPath('user_preference_locale', null)
|
|
||||||
->assertJsonPath('workspace_default_locale', null);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
|
|
||||||
])
|
|
||||||
->getJson('/localization/context?plane=system')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'de')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
|
|
||||||
});
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
|
|
||||||
it('allows users to save and clear a personal locale preference over workspace default', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: LocaleResolver::SETTING_DOMAIN,
|
|
||||||
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
|
|
||||||
value: 'de',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->getJson('/localization/context')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'de')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->post(route('localization.preference.update'), ['preferred_locale' => 'en'])
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
expect($user->refresh()->preferred_locale)->toBe('en');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->getJson('/localization/context')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'en')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->post(route('localization.preference.update'), ['preferred_locale' => ''])
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
expect($user->refresh()->preferred_locale)->toBeNull();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->getJson('/localization/context')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'de')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows temporary overrides to win until cleared', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
$user->forceFill(['preferred_locale' => 'en'])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->post(route('localization.override.update'), ['locale' => 'de'])
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
|
|
||||||
])
|
|
||||||
->getJson('/localization/context')
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertJsonPath('locale', 'de')
|
|
||||||
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
|
|
||||||
])
|
|
||||||
->delete(route('localization.override.clear'))
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull();
|
|
||||||
});
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
|
|
||||||
it('formats locale preference feedback in the resolved locale', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
|
|
||||||
])
|
|
||||||
->post(route('localization.preference.update'), ['preferred_locale' => 'de'])
|
|
||||||
->assertRedirect()
|
|
||||||
->assertSessionHas('status', 'Spracheinstellung gespeichert.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats override feedback in the newly effective locale', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: LocaleResolver::SETTING_DOMAIN,
|
|
||||||
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
|
|
||||||
value: 'de',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->post(route('localization.override.update'), ['locale' => 'de'])
|
|
||||||
->assertRedirect()
|
|
||||||
->assertSessionHas('status', 'Sprachüberschreibung angewendet.');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
|
||||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'en',
|
|
||||||
])
|
|
||||||
->delete(route('localization.override.clear'))
|
|
||||||
->assertRedirect()
|
|
||||||
->assertSessionHas('status', 'Sprachüberschreibung gelöscht.');
|
|
||||||
});
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
|
|
||||||
it('keeps audit action identifiers and machine values invariant while UI locale is German', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
App::setLocale('de');
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: LocaleResolver::SETTING_DOMAIN,
|
|
||||||
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
|
|
||||||
value: 'de',
|
|
||||||
);
|
|
||||||
|
|
||||||
$audit = AuditLog::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
|
|
||||||
->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN)
|
|
||||||
->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE)
|
|
||||||
->and(data_get($audit->metadata, 'after_value'))->toBe('de');
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Illuminate\Support\Facades\Lang;
|
|
||||||
|
|
||||||
it('falls back to English for missing German translation lines', function (): void {
|
|
||||||
Lang::addLines(['localization.fallback_probe' => 'English fallback probe'], 'en');
|
|
||||||
|
|
||||||
App::setFallbackLocale('en');
|
|
||||||
App::setLocale('de');
|
|
||||||
|
|
||||||
expect(__('localization.fallback_probe'))->toBe('English fallback probe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not expose raw translation keys for supported first-wave catalogs', function (): void {
|
|
||||||
App::setLocale('de');
|
|
||||||
|
|
||||||
expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft')
|
|
||||||
->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings')
|
|
||||||
->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported');
|
|
||||||
});
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Services\Localization\LocaleResolver;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('persists workspace default locale through the existing workspace settings page', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
|
||||||
->assertSuccessful();
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->assertSet('data.localization_default_locale', null)
|
|
||||||
->set('data.localization_default_locale', 'de')
|
|
||||||
->callAction('save')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.localization_default_locale', 'de');
|
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE))
|
|
||||||
->toBe('de');
|
|
||||||
|
|
||||||
expect(WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', LocaleResolver::SETTING_DOMAIN)
|
|
||||||
->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE)
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps workspace default locale authorization aligned to settings capabilities', function (): void {
|
|
||||||
[$workspace, $user] = localizationWorkspaceMember('readonly');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
|
||||||
->assertSuccessful();
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->assertSet('data.localization_default_locale', null)
|
|
||||||
->assertActionVisible('save')
|
|
||||||
->assertActionDisabled('save')
|
|
||||||
->call('save')
|
|
||||||
->assertStatus(403);
|
|
||||||
});
|
|
||||||
@ -59,18 +59,15 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps stale acknowledged metadata as passive data only', function (): void {
|
it('supports legacy model helper compatibility for acknowledge', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([
|
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||||
'status' => 'acknowledged',
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($finding->status)->toBe('acknowledged')
|
$finding->acknowledge($user);
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED)
|
||||||
->and($finding->acknowledged_at)->not->toBeNull()
|
->and($finding->acknowledged_at)->not->toBeNull()
|
||||||
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
|
->and($finding->acknowledged_by_user_id)->toBe($user->getKey());
|
||||||
->and($finding->hasOpenStatus())->toBeFalse();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes v2 open and terminal status helpers', function (): void {
|
it('exposes v2 open and terminal status helpers', function (): void {
|
||||||
@ -87,26 +84,31 @@
|
|||||||
Finding::STATUS_RISK_ACCEPTED,
|
Finding::STATUS_RISK_ACCEPTED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses());
|
expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not treat acknowledged as canonical in v2 helpers', function (): void {
|
it('maps legacy acknowledged status to triaged in v2 helpers', function (): void {
|
||||||
expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged');
|
expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED))
|
||||||
|
->toBe(Finding::STATUS_TRIAGED);
|
||||||
|
|
||||||
expect(Finding::isOpenStatus('acknowledged'))->toBeFalse();
|
expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue();
|
||||||
expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse();
|
expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects resolving a stale acknowledged finding', function (): void {
|
it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([
|
$finding = Finding::factory()->for($tenant)->permissionPosture()->acknowledged()->create([
|
||||||
'status' => 'acknowledged',
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
'acknowledged_by_user_id' => $user->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED))
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
->toThrow(\InvalidArgumentException::class, 'Only open findings can be resolved.');
|
|
||||||
|
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||||
|
->and($finding->acknowledged_at)->not->toBeNull()
|
||||||
|
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
|
||||||
|
->and($finding->resolved_at)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has STATUS_RESOLVED constant', function (): void {
|
it('has STATUS_RESOLVED constant', function (): void {
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Support\Audit\AuditOutcome;
|
use App\Support\Audit\AuditOutcome;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
|
|
||||||
it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
|
it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -37,7 +36,7 @@
|
|||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
$run = $operationRunService->ensureRunWithIdentity(
|
$run = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -98,7 +97,7 @@
|
|||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
$run = $operationRunService->ensureRunWithIdentity(
|
$run = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: OperationRunType::BaselineCapture->value,
|
type: 'baseline_capture',
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee($workspaceName ?? 'Select workspace')
|
->assertSee($workspaceName ?? 'Select workspace')
|
||||||
->assertSee(__('localization.shell.search_tenants'))
|
->assertSee('Search tenants…')
|
||||||
->assertSee('Switch workspace')
|
->assertSee('Switch workspace')
|
||||||
->assertSee('admin/select-tenant')
|
->assertSee('admin/select-tenant')
|
||||||
->assertSee('Clear tenant scope')
|
->assertSee('Clear tenant scope')
|
||||||
@ -66,7 +66,7 @@
|
|||||||
->get('/admin/workspaces')
|
->get('/admin/workspaces')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Choose a workspace first.')
|
->assertSee('Choose a workspace first.')
|
||||||
->assertDontSee(__('localization.shell.search_tenants'));
|
->assertDontSee('Search tenants…');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
|
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user