Compare commits

..

7 Commits

Author SHA1 Message Date
Ahmed Darrazi
dfc91d055a feat(reviews): add CustomerReviewWorkspace with audit logging and RBAC enforcement
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m43s
- Add CustomerReviewWorkspace page for tenant pre-filtered reviews
- Add customer workspace links to EvidenceSnapshotResource, ReviewPackResource, and TenantReviewResource
- Implement audit logging for TenantReviewOpened and ReviewPackDownloaded actions
- Update ReviewPack download controller to enforce tenant-scoped RBAC
- Add tests for ReviewPack download authorization and audit logging
2026-04-28 09:10:56 +02:00
Ahmed Darrazi
f7bc4f2787 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 23:22:08 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
208 changed files with 4187 additions and 12924 deletions

View File

@ -262,10 +262,6 @@ ## Active Technologies
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace) - PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (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) - 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)
- 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 +296,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
- 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`
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

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

View File

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

View File

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

View File

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

View File

@ -1,494 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
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\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class GovernanceInbox extends Page
{
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Governance inbox';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Governance inbox';
protected static ?string $slug = 'governance/inbox';
protected string $view = 'filament.pages.governance.governance-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleFindingTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $reviewTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $inboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredInboxPayload = null;
private ?Workspace $workspace = null;
private ?bool $visibleAlertsFamily = null;
public ?int $tenantId = null;
public ?string $family = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
}
public function mount(): void
{
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->family = $this->resolveRequestedFamily();
$this->ensureAtLeastOneVisibleFamily();
$this->ensureRequestedFamilyIsVisible();
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$selectedTenant = $this->selectedTenant();
$availableFamilies = collect($this->availableFamilies())
->keyBy('key');
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $selectedTenant?->name,
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
'family_key' => $this->family,
'family_label' => $this->family !== null
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
: 'All attention',
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableFamilies(): array
{
return $this->inboxPayload()['available_families'] ?? [];
}
/**
* @return list<array<string, mixed>>
*/
public function sections(): array
{
return $this->inboxPayload()['sections'] ?? [];
}
/**
* @return array<string, mixed>
*/
public function calmEmptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'This tenant filter is hiding other visible attention',
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
'action_label' => 'Clear tenant filter',
'action_url' => $this->pageUrl(['tenant' => null]),
];
}
return [
'title' => 'No visible governance attention right now',
'body' => 'The current workspace scope is calm across the visible governance families.',
'action_label' => null,
'action_url' => null,
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveFamily(?string $familyKey): bool
{
return $this->family === $familyKey;
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
: $this->family;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(),
);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureAtLeastOneVisibleFamily(): void
{
if (
$this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== []
|| $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily()
) {
return;
}
abort(403);
}
private function ensureRequestedFamilyIsVisible(): void
{
if ($this->family === null) {
return;
}
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
return;
}
throw new NotFoundHttpException;
}
private function hasVisibleOperationsFamily(): bool
{
return $this->authorizedTenants() !== [];
}
private function hasVisibleAlertsFamily(): bool
{
if (is_bool($this->visibleAlertsFamily)) {
return $this->visibleAlertsFamily;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleAlertsFamily = false;
}
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
/**
* @return array<int, Tenant>
*/
private function visibleFindingTenants(): array
{
if ($this->visibleFindingTenants !== null) {
return $this->visibleFindingTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleFindingTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleFindingTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
/**
* @return array<int, Tenant>
*/
private function reviewTenants(): array
{
if ($this->reviewTenants !== null) {
return $this->reviewTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->reviewTenants = [];
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
return $this->reviewTenants = [];
}
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedFamily(): ?string
{
$family = request()->query('family');
if (! is_string($family)) {
return null;
}
return in_array($family, [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
], true) ? $family : null;
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<string, mixed>
*/
private function inboxPayload(): array
{
if (is_array($this->inboxPayload)) {
return $this->inboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->inboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredInboxPayload(): array
{
if (is_array($this->unfilteredInboxPayload)) {
return $this->unfilteredInboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->unfilteredInboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: null,
selectedFamily: null,
navigationContext: $this->navigationContext(),
);
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->family !== null) {
return false;
}
if ($this->sections() !== []) {
return false;
}
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
}
}

View File

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

View File

@ -178,23 +178,9 @@ public function table(Table $table): Table
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant) && auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true)) && in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)) ->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(function (TenantReview $record): ?string { ->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant); ? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
if ((bool) ($decision['is_blocked'] ?? false)) {
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
if ((bool) ($decision['is_warning'] ?? false)) {
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
return null;
})
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)), ->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
]) ])
->bulkActions([]) ->bulkActions([])

View File

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

View File

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

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver; use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService; use App\Services\Providers\ProviderConnectionMutationService;
@ -4552,30 +4551,27 @@ private function completionSummaryEntitlementDecision(): array
return []; return [];
} }
return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision( return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace, $this->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
); );
} }
private function completionSummaryEntitlementBlocked(): bool private function completionSummaryEntitlementBlocked(): bool
{ {
return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK; return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
} }
private function completionSummaryEntitlementSummary(): string private function completionSummaryEntitlementSummary(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; $currentUsage = (int) ($decision['current_usage'] ?? 0);
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); $effectiveValue = (int) ($decision['effective_value'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
return sprintf( return sprintf(
'%s - %s - %d active of %d allowed (%s)', '%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed', $this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage, $currentUsage,
$effectiveValue, $effectiveValue,
$sourceLabel, $sourceLabel,
@ -4585,15 +4581,13 @@ private function completionSummaryEntitlementSummary(): string
private function completionSummaryEntitlementDetail(): string private function completionSummaryEntitlementDetail(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; $currentUsage = (int) ($decision['current_usage'] ?? 0);
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); $effectiveValue = (int) ($decision['effective_value'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); $remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0); $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf( $message = sprintf(
'%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', 'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
(string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'),
$currentUsage, $currentUsage,
$currentUsage === 1 ? '' : 's', $currentUsage === 1 ? '' : 's',
$effectiveValue, $effectiveValue,
@ -4612,7 +4606,7 @@ private function completionSummaryEntitlementDetail(): string
} }
} }
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) { if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale; $message .= ' Rationale: '.$rationale;
} }
@ -4988,7 +4982,7 @@ public function completeOnboarding(): void
if ($this->completionSummaryEntitlementBlocked()) { if ($this->completionSummaryEntitlementBlocked()) {
Notification::make() Notification::make()
->title('Activation unavailable') ->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail()) ->body($this->completionSummaryEntitlementDetail())
->warning() ->warning()
->send(); ->send();

View File

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

View File

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

View File

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

View File

@ -575,19 +575,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null; return is_string($reason) && $reason !== '' ? $reason : null;
} }
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::currentTenantContext(); $tenant ??= static::currentTenantContext();
@ -597,7 +584,6 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant) return static::reviewPackGenerationBlockReason($tenant);
?? static::reviewPackGenerationWarningReason($tenant);
} }
} }

View File

@ -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)),
]); ]);
} }
@ -488,19 +464,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null; return is_string($reason) && $reason !== '' ? $reason : null;
} }
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::panelTenantContext(); $tenant ??= static::panelTenantContext();
@ -510,8 +473,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant) return static::reviewPackGenerationBlockReason($tenant);
?? static::reviewPackGenerationWarningReason($tenant);
} }
public static function executeExport(TenantReview $review): void public static function executeExport(TenantReview $review): void
@ -520,7 +482,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 +499,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 +511,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 +526,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 +539,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 +579,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 +603,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 +615,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 +634,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.',
]; ];
} }

View File

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

View File

@ -9,19 +9,13 @@
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery; use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow; use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -100,77 +94,6 @@ public function workspaceEntitlementSummary(): array
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace); return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
} }
/**
* @return array<string, mixed>
*/
public function workspaceCommercialLifecycleSummary(): array
{
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('change_commercial_state')
->label('Change commercial state')
->icon('heroicon-o-adjustments-horizontal')
->color('warning')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->requiresConfirmation()
->modalHeading('Change commercial state')
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
->form([
Select::make('state')
->label('Commercial state')
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
->required()
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
Textarea::make('reason')
->label('Rationale')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, SettingsWriter $settingsWriter): void {
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
abort(403);
}
$settingsWriter->updateWorkspaceCommercialLifecycle(
actor: $actor,
workspace: $this->workspace,
state: (string) ($data['state'] ?? ''),
reason: (string) ($data['reason'] ?? ''),
);
$this->workspace->refresh();
Notification::make()
->title('Commercial state updated')
->success()
->send();
}),
];
}
private function canManageCommercialLifecycle(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
}
/** /**
* @return array{ * @return array{
* overall: array{label: string, color: string, icon: string|null}, * overall: array{label: string, color: string, icon: string|null},

View File

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

View File

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

View File

@ -81,14 +81,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return; return;
} }
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
Notification::make()
->title('Review pack generation allowed with warning')
->body($decision['warning_reason'])
->warning()
->send();
}
$activeRun = $service->checkActiveRun($tenant) $activeRun = $service->checkActiveRun($tenant)
? OperationRun::query() ? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -171,9 +163,6 @@ protected function getViewData(): array
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null) $generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason'] ? $generationEntitlement['block_reason']
: null; : null;
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
? $generationEntitlement['warning_reason']
: null;
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun']) ->with(['tenantReview', 'operationRun'])
@ -192,7 +181,6 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
@ -244,7 +232,6 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
@ -278,7 +265,6 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'generationBlocked' => false, 'generationBlocked' => false,
'generationBlockReason' => null, 'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null, 'customerWorkspaceUrl' => null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,

View File

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

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
@ -79,16 +78,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 +114,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 +135,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(
@ -181,7 +180,6 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class, InventoryCoverage::class,
TenantRequiredPermissions::class, TenantRequiredPermissions::class,
WorkspaceSettings::class, WorkspaceSettings::class,
GovernanceInbox::class,
FindingsHygieneReport::class, FindingsHygieneReport::class,
FindingsIntakeQueue::class, FindingsIntakeQueue::class,
MyFindingsInbox::class, MyFindingsInbox::class,
@ -210,7 +208,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,
]); ]);

View File

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

View File

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

View File

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

View File

@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
final class WorkspaceCommercialLifecycleResolver
{
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
public const STATE_TRIAL = 'trial';
public const STATE_GRACE = 'grace';
public const STATE_ACTIVE_PAID = 'active_paid';
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
public const ACTION_EVIDENCE_READ = 'evidence_read';
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
public const OUTCOME_ALLOW = 'allow';
public const OUTCOME_WARN = 'warn';
public const OUTCOME_BLOCK = 'block';
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
) {}
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_GRACE,
self::STATE_ACTIVE_PAID,
self::STATE_SUSPENDED_READ_ONLY,
];
}
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
self::STATE_TRIAL => 'Trial',
self::STATE_GRACE => 'Grace',
self::STATE_ACTIVE_PAID => 'Active paid',
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
];
}
/**
* @return array<string, string>
*/
public static function stateDescriptions(): array
{
return [
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
];
}
/**
* @return array<string, mixed>
*/
public function summary(Workspace $workspace): array
{
$lifecycle = $this->resolve($workspace);
return $lifecycle + [
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
'action_decisions' => [
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
],
];
}
/**
* @return array{
* workspace_id: int,
* state: string,
* state_label: string,
* source: string,
* source_label: string,
* rationale: string|null,
* description: string,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$state = in_array($rawState, self::stateIds(), true)
? $rawState
: self::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? self::SOURCE_WORKSPACE_SETTING
: self::SOURCE_DEFAULT_ACTIVE_PAID;
$rationale = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
return [
'workspace_id' => (int) $workspace->getKey(),
'state' => $state,
'state_label' => $labels[$state],
'source' => $source,
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
? 'workspace setting'
: 'default active paid',
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
'description' => $descriptions[$state],
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array<string, mixed>|null $lifecycle
* @return array<string, mixed>
*/
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
{
$lifecycle ??= $this->resolve($workspace);
return match ($actionKey) {
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ,
self::ACTION_EVIDENCE_READ,
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
};
}
/**
* @return array<string, mixed>
*/
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_WARN,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function readOnlyDecision(string $actionKey, array $lifecycle): array
{
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW_READ_ONLY,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
substrateDecision: null,
);
}
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
* @return array<string, mixed>
*/
private function decision(
array $lifecycle,
string $actionKey,
string $outcome,
?string $reasonFamily,
string $message,
?array $substrateDecision,
): array {
return [
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
'action_key' => $actionKey,
'outcome' => $outcome,
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
'is_warning' => $outcome === self::OUTCOME_WARN,
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
'message' => $message,
'reason_family' => $reasonFamily,
'state' => (string) $lifecycle['state'],
'state_label' => (string) $lifecycle['state_label'],
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'rationale' => $lifecycle['rationale'] ?? null,
'entitlement_decision' => $substrateDecision,
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_type', 'workspace_setting')
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('recorded_at')
->latest('id')
->first();
if ($audit instanceof AuditLog) {
return [
'last_changed_at' => $audit->recorded_at,
'last_changed_by' => $audit->actorDisplayLabel(),
];
}
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
])
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
}

View File

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

View File

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

View File

@ -14,7 +14,7 @@
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest; use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver; use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -30,7 +30,7 @@ public function __construct(
private OperationRunService $operationRunService, private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver, private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger, private WorkspaceAuditLogger $auditLogger,
private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver, private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder, private ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
@ -253,22 +253,10 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
*/ */
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{ {
$tenant->loadMissing('workspace'); return $this->workspaceEntitlementResolver->resolve(
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace, $tenant->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
); );
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']
: [];
return $decision + [
'effective_value' => $entitlementDecision['effective_value'] ?? null,
'source' => $decision['source'] ?? null,
'current_usage' => $entitlementDecision['current_usage'] ?? null,
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
];
} }
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void

View File

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

View File

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

View File

@ -4,7 +4,6 @@
namespace App\Services\Settings; namespace App\Services\Settings;
use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantSetting; use App\Models\TenantSetting;
use App\Models\User; use App\Models\User;
@ -12,14 +11,11 @@
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry; use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -37,7 +33,27 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{ {
$this->authorizeManage($actor, $workspace); $this->authorizeManage($actor, $workspace);
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey()); $definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => (int) $actor->getKey(),
]);
$this->resolver->clearCache(); $this->resolver->clearCache();
@ -51,7 +67,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace', 'scope' => 'workspace',
'domain' => $domain, 'domain' => $domain,
'key' => $key, 'key' => $key,
'before_value' => $result['before_value'], 'before_value' => $beforeValue,
'after_value' => $afterValue, 'after_value' => $afterValue,
], ],
], ],
@ -60,79 +76,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key, resourceId: $domain.'.'.$key,
); );
return $result['setting']; return $setting;
}
public function updateWorkspaceCommercialLifecycle(
PlatformUser $actor,
Workspace $workspace,
string $state,
string $reason,
): void {
$state = strtolower(trim($state));
$reason = trim($reason);
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
}
if ($reason === '') {
throw ValidationException::withMessages([
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
]);
}
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
$stateResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
value: $state,
updatedByUserId: null,
);
$reasonResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
value: $reason,
updatedByUserId: null,
);
$this->resolver->clearCache();
$afterState = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$afterReason = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSettingUpdated->value,
context: [
'metadata' => [
'scope' => 'workspace',
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
'before_state' => $stateResult['before_value'],
'after_state' => $afterState,
'before_reason' => $reasonResult['before_value'],
'after_reason' => $afterReason,
],
],
actor: $actor,
resourceType: 'workspace_setting',
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
targetLabel: 'Commercial lifecycle state',
);
});
} }
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
@ -230,39 +174,6 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
]); ]);
} }
/**
* @return array{setting: WorkspaceSetting, before_value: mixed}
*/
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
{
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => $updatedByUserId,
]);
return [
'setting' => $setting,
'before_value' => $beforeValue,
];
}
private function validatedValue(SettingDefinition $definition, mixed $value): mixed private function validatedValue(SettingDefinition $definition, mixed $value): mixed
{ {
$validator = Validator::make( $validator = Validator::make(

View File

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

View File

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

View File

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

View File

@ -18,8 +18,6 @@ class PlatformCapabilities
public const DIRECTORY_VIEW = 'platform.directory.view'; public const DIRECTORY_VIEW = 'platform.directory.view';
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
public const OPERATIONS_VIEW = 'platform.operations.view'; public const OPERATIONS_VIEW = 'platform.operations.view';
public const OPERATIONS_MANAGE = 'platform.operations.manage'; public const OPERATIONS_MANAGE = 'platform.operations.manage';
@ -30,6 +28,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';
/** /**

View File

@ -57,7 +57,6 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,

View File

@ -48,7 +48,6 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status'; case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type'; case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status'; case ReviewPackStatus = 'review_pack_status';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness'; case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status'; case TenantReviewStatus = 'tenant_review_status';

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class CommercialLifecycleStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
default => BadgeSpec::unknown(),
};
}
}

View File

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

View File

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

View File

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

View File

@ -1,888 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\GovernanceInbox;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder
{
private const PREVIEW_LIMIT = 3;
/**
* @var list<string>
*/
private const FAMILY_ORDER = [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
];
public function __construct(
private TenantBackupHealthResolver $backupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
private TenantReviewRegisterService $tenantReviewRegisterService,
) {}
/**
* @param array<int, Tenant> $authorizedTenants
* @param array<int, Tenant> $visibleFindingTenants
* @param array<int, Tenant> $reviewTenants
* @return array{
* sections: list<array<string, mixed>>,
* available_families: list<array{key: string, label: string, count: int}>,
* family_counts: array<string, int>,
* total_count: int,
* }
*/
public function build(
User $user,
Workspace $workspace,
array $authorizedTenants,
array $visibleFindingTenants,
array $reviewTenants,
bool $canViewAlerts,
?Tenant $selectedTenant = null,
?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null,
): array {
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
$visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants);
$reviewTenantsById = $this->indexTenants($reviewTenants);
$allSections = [];
$availableFamilies = [];
$familyCounts = [];
if ($visibleFindingTenantsById !== []) {
$assignedSection = $this->assignedFindingsSection(
user: $user,
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$assignedSection['key']] = $assignedSection;
$availableFamilies[] = [
'key' => $assignedSection['key'],
'label' => $assignedSection['label'],
'count' => $assignedSection['count'],
];
$familyCounts[$assignedSection['key']] = $assignedSection['count'];
$intakeSection = $this->intakeFindingsSection(
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$intakeSection['key']] = $intakeSection;
$availableFamilies[] = [
'key' => $intakeSection['key'],
'label' => $intakeSection['label'],
'count' => $intakeSection['count'],
];
$familyCounts[$intakeSection['key']] = $intakeSection['count'];
}
if ($authorizedTenantsById !== []) {
$operationsSection = $this->operationsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$operationsSection['key']] = $operationsSection;
$availableFamilies[] = [
'key' => $operationsSection['key'],
'label' => $operationsSection['label'],
'count' => $operationsSection['count'],
];
$familyCounts[$operationsSection['key']] = $operationsSection['count'];
}
if ($canViewAlerts) {
$alertsSection = $this->alertsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$alertsSection['key']] = $alertsSection;
$availableFamilies[] = [
'key' => $alertsSection['key'],
'label' => $alertsSection['label'],
'count' => $alertsSection['count'],
];
$familyCounts[$alertsSection['key']] = $alertsSection['count'];
}
if ($reviewTenantsById !== []) {
$reviewSection = $this->reviewFollowUpSection(
user: $user,
workspace: $workspace,
reviewTenants: $reviewTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$reviewSection['key']] = $reviewSection;
$availableFamilies[] = [
'key' => $reviewSection['key'],
'label' => $reviewSection['label'],
'count' => $reviewSection['count'],
];
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
}
$sections = [];
foreach (self::FAMILY_ORDER as $familyKey) {
$section = $allSections[$familyKey] ?? null;
if (! is_array($section)) {
continue;
}
if ($selectedFamily !== null) {
if ($familyKey === $selectedFamily) {
$sections[] = $section;
}
continue;
}
if ((int) ($section['count'] ?? 0) > 0) {
$sections[] = $section;
}
}
return [
'sections' => $sections,
'available_families' => $availableFamilies,
'family_counts' => $familyCounts,
'total_count' => array_sum($familyCounts),
];
}
/**
* @param array<int, Tenant> $tenants
* @return array<int, Tenant>
*/
private function indexTenants(array $tenants): array
{
$indexed = [];
foreach ($tenants as $tenant) {
$indexed[(int) $tenant->getKey()] = $tenant;
}
return $indexed;
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function assignedFindingsSection(
User $user,
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$overdueCount = (clone $baseQuery)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count();
$entries = $this->orderedAssignedFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10))
->all();
return [
'key' => 'assigned_findings',
'label' => 'Assigned findings',
'count' => $count,
'summary' => $this->assignedFindingsSummary($count, $overdueCount),
'dominant_action_label' => 'Open my findings',
'dominant_action_url' => $this->appendQuery(
MyFindingsInbox::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No assigned findings match this tenant filter right now.'
: 'No assigned findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function intakeFindingsSection(
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$needsTriageCount = (clone $baseQuery)
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->count();
$entries = $this->orderedIntakeFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20))
->all();
return [
'key' => 'intake_findings',
'label' => 'Findings intake',
'count' => $count,
'summary' => $this->intakeFindingsSummary($count, $needsTriageCount),
'dominant_action_label' => 'Open findings intake',
'dominant_action_url' => $this->appendQuery(
FindingsIntakeQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No intake findings match this tenant filter right now.'
: 'No intake findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function operationsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$terminalCount = (clone $terminalQuery)->count();
$staleCount = (clone $staleQuery)->count();
$entries = array_merge(
(clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
(clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
);
$entries = collect($entries)
->unique(fn (OperationRun $run): int => (int) $run->getKey())
->sortBy([
fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
fn (OperationRun $run): int => -1 * (int) $run->getKey(),
])
->take(self::PREVIEW_LIMIT)
->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext))
->values()
->all();
$dominantProblemClass = $terminalCount > 0
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
return [
'key' => 'stale_operations',
'label' => 'Operations follow-up',
'count' => $terminalCount + $staleCount,
'summary' => $this->operationsSummary($terminalCount, $staleCount),
'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations',
'dominant_action_url' => OperationRunLinks::index(
tenant: $selectedTenant,
context: $navigationContext,
problemClass: $dominantProblemClass,
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No stale or terminal follow-up operations match this tenant filter right now.'
: 'No stale or terminal follow-up operations are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function alertsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$entries = (clone $baseQuery)
->latest('created_at')
->latest('id')
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext))
->all();
return [
'key' => 'alert_delivery_failures',
'label' => 'Alert delivery failures',
'count' => $count,
'summary' => $this->alertsSummary($count),
'dominant_action_label' => 'Open alert deliveries',
'dominant_action_url' => $this->appendQuery(
AlertDeliveryResource::getUrl(panel: 'admin'),
array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'tableFilters' => array_filter([
'status' => ['value' => AlertDelivery::STATUS_FAILED],
'tenant_id' => $selectedTenant instanceof Tenant
? ['value' => (string) $selectedTenant->getKey()]
: null,
], static fn (mixed $value): bool => $value !== null),
],
),
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No failed alert deliveries match this tenant filter right now.'
: 'No failed alert deliveries are visible right now.',
];
}
/**
* @param array<int, Tenant> $reviewTenants
* @return array<string, mixed>
*/
private function reviewFollowUpSection(
User $user,
Workspace $workspace,
array $reviewTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($reviewTenants);
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
workspaceId: (int) $workspace->getKey(),
tenantIds: $tenantIds,
backupHealthByTenant: $backupHealthByTenant,
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
);
$latestPublishedReviews = $this->tenantReviewRegisterService
->latestPublishedQuery($user, $workspace)
->get()
->keyBy('tenant_id')
->all();
$rawEntries = [];
foreach ($tenantIds as $tenantId) {
$tenant = $reviewTenants[$tenantId] ?? null;
$rows = $resolved['rows'][$tenantId] ?? null;
if (! $tenant instanceof Tenant || ! is_array($rows)) {
continue;
}
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$row = $rows[$family] ?? null;
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
continue;
}
$derivedState = $row['derived_state'] ?? null;
if (! in_array($derivedState, [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
], true)) {
continue;
}
$rawEntries[] = $this->reviewEntry(
tenant: $tenant,
family: $family,
row: $row,
latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null,
navigationContext: $navigationContext,
);
}
}
usort($rawEntries, function (array $left, array $right): int {
$leftRank = (int) ($left['urgency_rank'] ?? 0);
$rightRank = (int) ($right['urgency_rank'] ?? 0);
if ($leftRank !== $rightRank) {
return $leftRank <=> $rightRank;
}
return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? ''));
});
$followUpCount = collect($rawEntries)
->where('status_label', 'Follow-up needed')
->count();
$changedCount = collect($rawEntries)
->where('status_label', 'Changed since review')
->count();
return [
'key' => 'review_follow_up',
'label' => 'Review follow-up',
'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up',
'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.'
: 'No review follow-up is visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
[now()],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->whereNull('assignee_user_id')
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when due_at is not null and due_at < ? then 0
when status = ? then 1
when status = ? then 2
else 3
end asc",
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->terminalFollowUp();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->activeStaleAttention();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return OperationRun::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return AlertDelivery::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where('status', AlertDelivery::STATUS_FAILED)
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @return array<string, mixed>
*/
private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array
{
$sublineParts = array_values(array_filter([
$finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null,
FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding),
$finding->reopened_at !== null ? 'Reopened' : null,
]));
return [
'family_key' => $familyKey,
'source_model' => Finding::class,
'source_key' => (string) $finding->getKey(),
'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null,
'tenant_label' => $finding->tenant?->name,
'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(),
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $baseUrgencyRank
+ ($finding->due_at?->isPast() === true ? 0 : 1)
+ ($finding->reopened_at !== null ? 0 : 1),
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
'destination_url' => $this->appendQuery(
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array
{
$problemClass = $run->problemClass();
return [
'family_key' => 'stale_operations',
'source_model' => OperationRun::class,
'source_key' => (string) $run->getKey(),
'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null,
'tenant_label' => $run->tenant?->name,
'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up operation'
: 'Stale active operation',
'subline' => OperationRunLinks::identifier($run),
'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: 'Stale',
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array
{
$payload = is_array($delivery->payload) ? $delivery->payload : [];
$headline = is_string($payload['title'] ?? null) && $payload['title'] !== ''
? (string) $payload['title']
: 'Failed alert delivery';
$sublineParts = array_values(array_filter([
is_string($delivery->last_error_message) && $delivery->last_error_message !== ''
? $delivery->last_error_message
: null,
is_string($delivery->event_type) && $delivery->event_type !== ''
? $delivery->event_type
: null,
]));
return [
'family_key' => 'alert_delivery_failures',
'source_model' => AlertDelivery::class,
'source_key' => (string) $delivery->getKey(),
'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null,
'tenant_label' => $delivery->tenant?->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => 0,
'status_label' => 'Failed',
'destination_url' => $this->appendQuery(
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function reviewEntry(
Tenant $tenant,
string $family,
array $row,
mixed $latestPublishedReview,
?CanonicalNavigationContext $navigationContext,
): array {
$state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
? 'Backup health'
: 'Recovery evidence';
$headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? $familyLabel.' needs review follow-up'
: $familyLabel.' changed since review';
$sublineParts = array_values(array_filter([
is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
? 'Last review: '.$row['reviewed_by_user_name']
: null,
isset($row['reviewed_at']) && $row['reviewed_at'] !== null
? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString()
: null,
]));
$destinationUrl = $latestPublishedReview !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
return [
'family_key' => 'review_follow_up',
'source_model' => TenantTriageReview::class,
'source_key' => (string) $tenant->getKey().':'.$family,
'tenant_id' => (int) $tenant->getKey(),
'tenant_label' => $tenant->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? 'Follow-up needed'
: 'Changed since review',
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
private function assignedFindingsSummary(int $count, int $overdueCount): string
{
if ($count === 0) {
return 'No assigned findings are visible in the current scope.';
}
if ($overdueCount > 0) {
return sprintf(
'%d assigned finding%s remain open. %d %s overdue.',
$count,
$count === 1 ? '' : 's',
$overdueCount,
$overdueCount === 1 ? 'is' : 'are',
);
}
return sprintf(
'%d assigned finding%s remain open in the visible scope.',
$count,
$count === 1 ? '' : 's',
);
}
private function intakeFindingsSummary(int $count, int $needsTriageCount): string
{
if ($count === 0) {
return 'No intake findings are visible in the current scope.';
}
return sprintf(
'%d unassigned finding%s remain in intake. %d still need first triage.',
$count,
$count === 1 ? '' : 's',
$needsTriageCount,
);
}
private function operationsSummary(int $terminalCount, int $staleCount): string
{
if ($terminalCount + $staleCount === 0) {
return 'No stale or terminal follow-up operations are visible in the current scope.';
}
if ($terminalCount > 0 && $staleCount > 0) {
return sprintf(
'%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
if ($terminalCount > 0) {
return sprintf(
'%d terminal follow-up operation%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
);
}
return sprintf(
'%d stale active run%s need monitoring attention.',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
private function alertsSummary(int $count): string
{
if ($count === 0) {
return 'No failed alert deliveries are visible in the current scope.';
}
return sprintf(
'%d failed alert delivery attempt%s remain visible in this workspace.',
$count,
$count === 1 ? '' : 's',
);
}
private function reviewSummary(int $followUpCount, int $changedCount): string
{
$total = $followUpCount + $changedCount;
if ($total === 0) {
return 'No review follow-up is visible in the current scope.';
}
return sprintf(
'%d review concern%s need attention. %d marked follow-up needed and %d changed since review.',
$total,
$total === 1 ? '' : 's',
$followUpCount,
$changedCount,
);
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.http_build_query($query);
}
}

View File

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

View File

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

View File

@ -4,10 +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\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry final class SettingsRegistry
@ -30,25 +28,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',
@ -335,44 +314,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
return $normalized === '' ? null : $normalized; return $normalized === '' ? null : $normalized;
}, },
)); ));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = strtolower(trim((string) $value));
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
} }
/** /**

View File

@ -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,
@ -744,17 +749,12 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery', 'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case', 'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail', 'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.', 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
'evidence' => [ 'evidence' => [
[ [
'kind' => 'feature_livewire_test', 'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php', 'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.', 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
], ],
[ [
'kind' => 'authorization_test', 'kind' => 'authorization_test',

View File

@ -8,8 +8,6 @@ final class WorkspaceResolver
{ {
public function resolve(string $value): ?Workspace public function resolve(string $value): ?Workspace
{ {
$value = $this->normalizeRouteValue($value);
$workspace = Workspace::query() $workspace = Workspace::query()
->where('slug', $value) ->where('slug', $value)
->first(); ->first();
@ -24,37 +22,4 @@ public function resolve(string $value): ?Workspace
return Workspace::query()->whereKey((int) $value)->first(); return Workspace::query()->whereKey((int) $value)->first();
} }
private function normalizeRouteValue(string $value): string
{
$value = trim($value);
if (! str_starts_with($value, '{')) {
return $value;
}
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return $value;
}
$slug = $decoded['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
$id = $decoded['id'] ?? null;
if (is_int($id)) {
return (string) $id;
}
if (is_string($id) && ctype_digit($id)) {
return $id;
}
return $value;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,164 +0,0 @@
<x-filament-panels::page>
@php
$scope = $this->appliedScope();
$sections = $this->sections();
$emptyState = $this->calmEmptyState();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
Governance inbox
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Governance inbox
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($scope['workspace_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Workspace: {{ $scope['workspace_label'] }}
</span>
@endif
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Scope: {{ $scope['family_label'] ?? 'All attention' }}
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Visible items: {{ $scope['total_count'] ?? 0 }}
</span>
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
Tenant: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
<a
href="{{ $this->pageUrl(['family' => null]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
All attention
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
</a>
@foreach ($this->availableFamilies() as $family)
<a
href="{{ $this->pageUrl(['family' => $family['key']]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
{{ $family['label'] }}
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
</a>
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The inbox is currently filtered to one tenant.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear tenant filter
</a>
</div>
@endif
</div>
</x-filament::section>
@if ($sections === [])
<x-filament::section>
<div class="flex flex-col gap-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-6 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] }}</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $emptyState['body'] }}</p>
</div>
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
<div>
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
{{ $emptyState['action_label'] }}
</x-filament::button>
</div>
@endif
</div>
</x-filament::section>
@else
@foreach ($sections as $section)
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h2>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $section['count'] }}
</span>
</div>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
</div>
<div>
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
{{ $section['dominant_action_label'] }}
</x-filament::button>
</div>
</div>
@if ($section['count'] === 0)
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
{{ $section['empty_state'] }}
</div>
@else
<ul class="grid gap-3">
@foreach ($section['entries'] as $entry)
<li class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1.5">
@if (filled($entry['tenant_label'] ?? null))
<div class="text-xs font-medium uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $entry['tenant_label'] }}
</div>
@endif
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $entry['destination_url'] }}" class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300">
{{ $entry['headline'] }}
</a>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $entry['status_label'] }}
</span>
</div>
@if (filled($entry['subline'] ?? null))
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
@endif
</div>
<div>
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
Open source
</x-filament::button>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</x-filament::section>
@endforeach
@endif
</x-filament-panels::page>

View File

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

View File

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

View File

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

View File

@ -1,18 +1,9 @@
@php @php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
/** @var \App\Models\Workspace $workspace */ /** @var \App\Models\Workspace $workspace */
$workspace = $this->workspace; $workspace = $this->workspace;
$customerHealthDecision = $this->customerHealthDecision(); $customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants(); $tenants = $this->workspaceTenants();
$runs = $this->recentRuns(); $runs = $this->recentRuns();
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
$readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null;
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary(); $workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null; $planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? []; $entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
@ -49,63 +40,6 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision]) @include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif @endif
<x-filament::section>
<x-slot name="heading">
Commercial lifecycle
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
{{ $commercialBadge->label }}
</x-filament::badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
· {{ $commercialLifecycle['last_changed_at']->diffForHumans() }}
@endif
</p>
</div>
</div>
<div class="mt-4 space-y-3">
@foreach ([
'Managed tenant activation' => $activationLifecycleDecision,
'Review-pack starts' => $reviewPackLifecycleDecision,
'Read-only history and downloads' => $readOnlyLifecycleDecision,
] as $label => $decision)
@if (is_array($decision))
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $label }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $decision['message'] ?? 'No lifecycle decision message available.' }}</p>
</div>
<x-filament::badge :color="match ($decision['outcome'] ?? null) {
'block' => 'danger',
'warn' => 'warning',
'allow_read_only' => 'info',
default => 'success',
}">
{{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }}
</x-filament::badge>
</div>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision)) @if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section> <x-filament::section>
<x-slot name="heading"> <x-slot name="heading">

View File

@ -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> </div>
</x-filament::section> </x-filament::section>
</div> </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>
</x-filament::section>
</div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -11,7 +11,6 @@
/** @var bool $canManage */ /** @var bool $canManage */
/** @var bool $generationBlocked */ /** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */ /** @var ?string $generationBlockReason */
/** @var ?string $generationWarningReason */
/** @var ?string $customerWorkspaceUrl */ /** @var ?string $customerWorkspaceUrl */
/** @var ?string $downloadUrl */ /** @var ?string $downloadUrl */
/** @var ?string $failedReason */ /** @var ?string $failedReason */
@ -34,12 +33,6 @@
</div> </div>
@endif @endif
@if ($canManage && ! $generationBlocked && $generationWarningReason)
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
{{ $generationWarningReason }}
</div>
@endif
@if (! $pack) @if (! $pack)
{{-- State 1: No pack --}} {{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center"> <div class="flex flex-col items-center gap-3 py-4 text-center">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,14 +10,10 @@
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
@ -72,23 +68,6 @@ function evidenceSnapshotHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions(); return $instance->getCachedHeaderActions();
} }
function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Evidence read-only preservation test',
);
}
it('renders the evidence list page for an authorized user', function (): void { it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -228,36 +207,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
->toContain('operation_run', 'review_pack'); ->toContain('operation_run', 'review_pack');
}); });
it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
suspendEvidenceSnapshotWorkspace($tenant);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant'))
->assertOk();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_evidence')
->assertActionDisabled('refresh_evidence')
->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot');
});
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void { it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More