refactor: remove findings lifecycle backfill runtime surfaces #294

Merged
ahmido merged 7 commits from 253-remove-findings-backfill-runtime-surfaces into platform-dev 2026-04-28 22:00:53 +00:00
145 changed files with 11295 additions and 3918 deletions

View File

@ -262,6 +262,10 @@ ## 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)
@ -296,9 +300,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

@ -1,129 +0,0 @@
<?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

@ -1,56 +0,0 @@
<?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,4 +9,9 @@
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

@ -0,0 +1,494 @@
<?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

@ -57,6 +57,21 @@ 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 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)
@ -84,7 +99,7 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('clear_filters') Action::make('clear_filters')
->label('Clear filters') ->label(__('localization.review.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())
@ -105,9 +120,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('Tenant')->searchable()->sortable(), TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review') TextColumn::make('latest_review')
->label('Latest review') ->label(__('localization.review.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))
@ -116,25 +131,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('Key findings') ->label(__('localization.review.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('Accepted risks') ->label(__('localization.review.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('Published') ->label(__('localization.review.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('Review pack') ->label(__('localization.review.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('Tenant') ->label(__('localization.review.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 {
@ -148,25 +163,25 @@ public function table(Table $table): Table
]) ])
->actions([ ->actions([
Action::make('open_latest_review') Action::make('open_latest_review')
->label('Open latest review') ->label(__('localization.review.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('Download review pack') ->label(__('localization.review.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('No entitled tenants match this view') ->emptyStateHeading(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters() ->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' ? __('localization.review.clear_filters_description')
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.') : __('localization.review.adjust_filters_description'))
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters_empty') Action::make('clear_filters_empty')
->label('Clear filters') ->label(__('localization.review.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())
@ -387,7 +402,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
private function latestReviewStateLabel(Tenant $tenant): string private function latestReviewStateLabel(Tenant $tenant): string
{ {
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
} }
private function latestReviewStateColor(Tenant $tenant): string private function latestReviewStateColor(Tenant $tenant): string
@ -410,7 +425,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason; $primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
@ -427,7 +442,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
return $primaryReason; return $primaryReason;
} }
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
} }
private function findingSummary(Tenant $tenant): string private function findingSummary(Tenant $tenant): string
@ -435,7 +450,7 @@ private function findingSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -444,14 +459,17 @@ 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 'No findings recorded in the published review.'; return __('localization.review.no_findings_recorded');
} }
if ($terminalOutcomes === null) { if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount); return __('localization.review.findings_count_summary', ['count' => $findingCount]);
} }
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
} }
private function acceptedRiskSummary(Tenant $tenant): string private function acceptedRiskSummary(Tenant $tenant): string
@ -459,7 +477,7 @@ private function acceptedRiskSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -469,10 +487,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 => 'No accepted risks recorded.', $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount), default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
}; };
} }
@ -481,17 +499,17 @@ private function reviewPackAvailability(Tenant $tenant): string
$pack = $this->latestReviewPack($tenant); $pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) { if (! $pack instanceof ReviewPack) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->status !== ReviewPackStatus::Ready->value) { if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->expires_at !== null && $pack->expires_at->isPast()) { if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
return 'Available'; return __('localization.review.available');
} }
} }

View File

@ -178,9 +178,23 @@ 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(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false) ->tooltip(function (TenantReview $record): ?string {
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '') $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
: 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,6 +12,7 @@
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;
@ -58,6 +59,7 @@ 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'],
@ -153,17 +155,22 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('save') Action::make('save')
->label('Save') ->label(__('localization.workspace.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
: 'You do not have permission to manage workspace settings.'), : __('localization.workspace.no_manage_permission')),
]; ];
} }
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)
@ -208,6 +215,18 @@ 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)
@ -507,7 +526,7 @@ public function save(): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') ->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
->success() ->success()
->send(); ->send();
} }
@ -526,7 +545,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) { if ($this->workspaceOverrideForField($field) === null) {
Notification::make() Notification::make()
->title('Setting already uses default') ->title(__('localization.notifications.setting_already_default'))
->success() ->success()
->send(); ->send();
@ -543,7 +562,7 @@ public function resetSetting(string $field): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title('Workspace setting reset to default') ->title(__('localization.notifications.workspace_setting_reset'))
->success() ->success()
->send(); ->send();
} }
@ -692,18 +711,17 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */ /** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at']; $updatedAt = $meta['updated_at'];
return sprintf( return __('localization.workspace.last_modified_by', [
'%s — Last modified by %s, %s.', 'description' => $baseDescription,
$baseDescription, 'user' => $meta['user_name'],
$meta['user_name'], 'time' => $updatedAt->diffForHumans(),
$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('Reset') ->label(__('localization.workspace.reset'))
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->action(function () use ($field): void { ->action(function () use ($field): void {
@ -718,15 +736,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 'You do not have permission to manage workspace settings.'; return __('localization.workspace.no_manage_permission');
} }
if (! $this->canResetField($field)) { if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) { if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return null; return null;
@ -948,6 +966,29 @@ 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;
@ -1353,9 +1394,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' => 'workspace override', 'workspace_override' => __('localization.source.workspace_override'),
'tenant_override' => 'tenant override', 'tenant_override' => 'tenant override',
default => 'system default', default => __('localization.source.system_default'),
}; };
} }

View File

@ -42,6 +42,11 @@ 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
*/ */
@ -90,38 +95,38 @@ public function authorizeTenantSupportRequest(): void
private function requestSupportAction(): Action private function requestSupportAction(): Action
{ {
$action = Action::make('requestSupport') $action = Action::make('requestSupport')
->label('Request support') ->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->color('gray') ->color('gray')
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Request support') ->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.') ->modalDescription(__('localization.dashboard.support_request_description'))
->modalSubmitActionLabel('Submit request') ->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([ ->form([
Placeholder::make('included_context') Placeholder::make('included_context')
->label('Included context') ->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(), ->columnSpanFull(),
Select::make('severity') Select::make('severity')
->label('Severity') ->label(__('localization.dashboard.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('Summary') ->label(__('localization.dashboard.summary'))
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
Textarea::make('reproduction_notes') Textarea::make('reproduction_notes')
->label('Reproduction notes') ->label(__('localization.dashboard.reproduction_notes'))
->rows(4) ->rows(4)
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('contact_name') TextInput::make('contact_name')
->label('Contact name') ->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveDashboardActor()->name), ->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email') TextInput::make('contact_email')
->label('Contact email') ->label(__('localization.dashboard.contact_email'))
->email() ->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email), ->default(fn (): ?string => $this->resolveDashboardActor()->email),
]) ])
@ -132,7 +137,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('Support request submitted') ->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference) ->body('Reference '.$supportRequest->internal_reference)
->success() ->success()
->send(); ->send();
@ -146,16 +151,16 @@ private function requestSupportAction(): Action
private function openSupportDiagnosticsAction(): Action private function openSupportDiagnosticsAction(): Action
{ {
$action = Action::make('openSupportDiagnostics') $action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics') ->label(__('localization.dashboard.open_support_diagnostics'))
->icon('heroicon-o-lifebuoy') ->icon('heroicon-o-lifebuoy')
->color('gray') ->color('gray')
->modal() ->modal()
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Support diagnostics') ->modalHeading(__('localization.dashboard.support_diagnostics'))
->modalDescription('Redacted tenant context from existing records.') ->modalDescription(__('localization.dashboard.support_diagnostics_description'))
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close')) ->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
->mountUsing(function (): void { ->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen(); $this->auditTenantSupportDiagnosticsOpen();
}) })

View File

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

View File

@ -75,8 +75,6 @@ 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') {
@ -86,6 +84,26 @@ 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();

View File

@ -10,14 +10,8 @@
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;
@ -77,15 +71,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant(); $stats = FindingResource::findingStatsForCurrentTenant();
return [ return [
'all' => Tab::make('All') 'all' => Tab::make(__('localization.findings.all'))
->icon('heroicon-m-list-bullet'), ->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make('Needs action') 'needs_action' => Tab::make(__('localization.findings.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('Overdue') 'overdue' => Tab::make(__('localization.findings.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())
@ -93,11 +87,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('Risk accepted') 'risk_accepted' => Tab::make(__('localization.findings.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('Resolved') 'resolved' => Tab::make(__('localization.findings.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])),
@ -108,77 +102,6 @@ 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')

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('Open approval queue') ->label(__('localization.findings.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('Actions') ->label(__('localization.findings.actions'))
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]); ]);

View File

@ -575,6 +575,19 @@ 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();
@ -584,6 +597,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);
} }
} }

View File

@ -85,6 +85,26 @@ 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();
@ -153,7 +173,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('Outcome summary') Section::make(__('localization.review.outcome_summary'))
->schema([ ->schema([
ViewEntry::make('artifact_truth') ViewEntry::make('artifact_truth')
->hiddenLabel() ->hiddenLabel()
@ -162,7 +182,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Review') Section::make(__('localization.review.review'))
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
@ -171,23 +191,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('Completeness') ->label(__('localization.review.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('Tenant'), TextEntry::make('tenant.name')->label(__('localization.review.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('Evidence snapshot') ->label(__('localization.review.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('Current export') ->label(__('localization.review.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)
@ -201,7 +221,7 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Executive posture') Section::make(__('localization.review.executive_posture'))
->schema([ ->schema([
ViewEntry::make('review_summary') ViewEntry::make('review_summary')
->hiddenLabel() ->hiddenLabel()
@ -210,21 +230,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Sections') Section::make(__('localization.review.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('Completeness') ->label(__('localization.review.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('Details') Section::make(__('localization.review.details'))
->schema([ ->schema([
ViewEntry::make('section_payload') ViewEntry::make('section_payload')
->hiddenLabel() ->hiddenLabel()
@ -246,7 +266,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('Export executive pack') ->label(__('localization.review.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,
@ -278,7 +298,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('Outcome') ->label(__('localization.review.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)
@ -289,10 +309,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('Export') ->label(__('localization.review.export'))
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('next_step') Tables\Columns\TextColumn::make('next_step')
->label('Next step') ->label(__('localization.review.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')
@ -306,18 +326,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', 'Review date', 'generated_at'), \App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
]) ])
->actions([ ->actions([
$exportExecutivePackAction, $exportExecutivePackAction,
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No tenant reviews yet') ->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') ->emptyStateDescription(__('localization.review.create_first_review_description'))
->emptyStateActions([ ->emptyStateActions([
static::makeCreateReviewAction( static::makeCreateReviewAction(
name: 'create_first_review', name: 'create_first_review',
label: 'Create first review', label: __('localization.review.create_first_review'),
icon: 'heroicon-o-plus', icon: 'heroicon-o-plus',
), ),
]); ]);
@ -336,19 +356,23 @@ 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('Evidence basis') Section::make(__('localization.review.evidence_basis'))
->schema([ ->schema([
Select::make('evidence_snapshot_id') Select::make('evidence_snapshot_id')
->label('Evidence snapshot') ->label(__('localization.review.evidence_snapshot'))
->required() ->required()
->options(fn (): array => static::evidenceSnapshotOptions()) ->options(fn (): array => static::evidenceSnapshotOptions())
->searchable() ->searchable()
->helperText('Choose the anchored evidence snapshot for this review.'), ->helperText(__('localization.review.evidence_basis_helper')),
]), ]),
]) ])
->action(fn (array $data): mixed => static::executeCreateReview($data)), ->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -366,7 +390,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('Unable to create review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
return; return;
} }
@ -388,7 +412,7 @@ public static function executeCreateReview(array $data): void
: null; : null;
if (! $snapshot instanceof EvidenceSnapshot) { if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
return; return;
} }
@ -396,7 +420,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('Unable to create review')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
return; return;
} }
@ -406,11 +430,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) { if (! $review->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Review already available') ->title(__('localization.review.review_already_available'))
->body('A matching mutable review already exists for this evidence basis.') ->body(__('localization.review.review_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_review') Actions\Action::make('view_review')
->label('View review') ->label(__('localization.review.view_review'))
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
]) ])
->send(); ->send();
@ -419,12 +443,12 @@ public static function executeCreateReview(array $data): void
} }
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body('The review is being composed in the background.'); ->body(__('localization.review.review_composing_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('Open operation') ->label(__('localization.review.open_operation'))
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]); ]);
} }
@ -464,6 +488,19 @@ 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();
@ -473,7 +510,8 @@ 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
@ -482,7 +520,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('Unable to export review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
return; return;
} }
@ -499,7 +537,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('An executive pack export is already queued or running for this review.') ->body(__('localization.review.export_already_queued_body'))
->send(); ->send();
return; return;
@ -511,11 +549,11 @@ public static function executeExport(TenantReview $review): void
'include_operations' => true, 'include_operations' => true,
]); ]);
} catch (WorkspaceEntitlementBlockedException $exception) { } catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send(); Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
return; return;
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
return; return;
} }
@ -526,11 +564,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) { if (! $pack->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Executive pack already available') ->title(__('localization.review.executive_pack_already_available'))
->body('A matching executive pack already exists for this review.') ->body(__('localization.review.executive_pack_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_pack') Actions\Action::make('view_pack')
->label('View pack') ->label(__('localization.review.view_pack'))
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
]) ])
->send(); ->send();
@ -539,7 +577,7 @@ public static function executeExport(TenantReview $review): void
} }
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The executive pack is being generated in the background.') ->body(__('localization.review.executive_pack_generating_background'))
->send(); ->send();
} }
@ -579,7 +617,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') ?? 'Pending' $snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
), ),
]) ])
->all(); ->all();
@ -603,7 +641,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[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; $highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
} }
return [ return [
@ -615,12 +653,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' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -634,37 +672,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' => 'Operation', 'title' => __('localization.review.operation'),
'label' => 'Open operation', 'label' => __('localization.review.open_operation'),
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => 'Inspect the latest review composition or refresh run.', 'description' => __('localization.review.operation_description'),
]; ];
} }
if ($record->currentExportReviewPack && $record->tenant) { if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Executive pack', 'title' => __('localization.review.executive_pack'),
'label' => 'View executive pack', 'label' => __('localization.review.view_executive_pack'),
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => 'Open the current export that belongs to this review.', 'description' => __('localization.review.executive_pack_description'),
]; ];
} }
if ($record->tenant) { if ($record->tenant) {
$links[] = [ $links[] = [
'title' => 'Customer workspace', 'title' => __('localization.review.customer_workspace'),
'label' => 'Open customer workspace', 'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', 'description' => __('localization.review.customer_workspace_description'),
]; ];
} }
if ($record->evidenceSnapshot && $record->tenant) { if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Evidence snapshot', 'title' => __('localization.review.evidence_snapshot'),
'label' => 'View evidence snapshot', 'label' => __('localization.review.view_evidence_snapshot'),
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.', 'description' => __('localization.review.evidence_snapshot_description'),
]; ];
} }

View File

@ -28,6 +28,11 @@ 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
*/ */
@ -109,12 +114,12 @@ protected function getHeaderActions(): array
return [ return [
Action::make('set_window') Action::make('set_window')
->label('Time window') ->label(__('localization.dashboard.time_window'))
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->color('gray') ->color('gray')
->form([ ->form([
Select::make('window') Select::make('window')
->label('Window') ->label(__('localization.dashboard.window'))
->options(SystemConsoleWindow::options()) ->options(SystemConsoleWindow::options())
->default($this->window) ->default($this->window)
->required(), ->required(),
@ -130,7 +135,7 @@ protected function getHeaderActions(): array
}), }),
Action::make('enter_break_glass') Action::make('enter_break_glass')
->label('Enter break-glass mode') ->label(__('localization.dashboard.enter_break_glass'))
->color('danger') ->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -158,13 +163,13 @@ protected function getHeaderActions(): array
$breakGlass->start($user, (string) ($data['reason'] ?? '')); $breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make() Notification::make()
->title('Recovery mode enabled') ->title(__('localization.dashboard.recovery_mode_enabled'))
->success() ->success()
->send(); ->send();
}), }),
Action::make('exit_break_glass') Action::make('exit_break_glass')
->label('Exit break-glass') ->label(__('localization.dashboard.exit_break_glass'))
->color('gray') ->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -180,7 +185,7 @@ protected function getHeaderActions(): array
$breakGlass->exit($user); $breakGlass->exit($user);
Notification::make() Notification::make()
->title('Recovery mode ended') ->title(__('localization.dashboard.recovery_mode_ended'))
->success() ->success()
->send(); ->send();
}), }),

View File

@ -9,13 +9,19 @@
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;
@ -94,6 +100,77 @@ 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

@ -4,26 +4,9 @@
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
{ {
@ -37,53 +20,6 @@ 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();
@ -95,231 +31,4 @@ 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,6 +81,14 @@ 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())
@ -163,6 +171,9 @@ 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'])
@ -181,6 +192,7 @@ 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,
@ -232,6 +244,7 @@ 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,
@ -265,6 +278,7 @@ 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

@ -0,0 +1,80 @@
<?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

@ -0,0 +1,29 @@
<?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

@ -1,398 +0,0 @@
<?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

@ -1,378 +0,0 @@
<?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

@ -1,95 +0,0 @@
<?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

@ -39,6 +39,7 @@ 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

@ -7,6 +7,7 @@
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;
@ -78,16 +79,16 @@ public function panel(Panel $panel): Panel
]) ])
->navigationItems([ ->navigationItems([
WorkspaceOverview::navigationItem(), WorkspaceOverview::navigationItem(),
NavigationItem::make('Integrations') NavigationItem::make(fn (): string => __('localization.navigation.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('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(15) ->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()), ->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make('Settings') NavigationItem::make(fn (): string => __('localization.navigation.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('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(20) ->sort(20)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -114,12 +115,12 @@ public function panel(Panel $panel): Panel
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
}), }),
NavigationItem::make('Manage workspaces') NavigationItem::make(fn (): string => __('localization.navigation.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('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(10) ->sort(10)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -135,15 +136,15 @@ public function panel(Panel $panel): Panel
->whereIn('role', $roles) ->whereIn('role', $roles)
->exists(); ->exists();
}), }),
NavigationItem::make('Operations') NavigationItem::make(fn (): string => __('localization.navigation.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('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.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('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -180,6 +181,7 @@ 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,
@ -208,6 +210,7 @@ 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,6 +42,14 @@ 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,
@ -59,6 +67,7 @@ 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(OperationRunLinks::collectionLabel()) NavigationItem::make(fn (): string => __('localization.navigation.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('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Alerts') NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
->url(fn (): string => url('/admin/alerts')) ->url(fn (): string => url('/admin/alerts'))
->icon('heroicon-o-bell-alert') ->icon('heroicon-o-bell-alert')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(20), ->sort(20),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.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('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -111,6 +111,7 @@ 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

@ -0,0 +1,410 @@
<?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

@ -0,0 +1,215 @@
<?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\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
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 WorkspaceEntitlementResolver $workspaceEntitlementResolver, private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
private ProductTelemetryRecorder $productTelemetryRecorder, private ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
@ -253,10 +253,22 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
*/ */
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{ {
return $this->workspaceEntitlementResolver->resolve( $tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace, $tenant->workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
); );
$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

@ -1,739 +0,0 @@
<?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

@ -1,81 +0,0 @@
<?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,6 +4,7 @@
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;
@ -11,11 +12,14 @@
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;
@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{ {
$this->authorizeManage($actor, $workspace); $this->authorizeManage($actor, $workspace);
$definition = $this->requireDefinition($domain, $key); $result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
$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();
@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace', 'scope' => 'workspace',
'domain' => $domain, 'domain' => $domain,
'key' => $key, 'key' => $key,
'before_value' => $beforeValue, 'before_value' => $result['before_value'],
'after_value' => $afterValue, 'after_value' => $afterValue,
], ],
], ],
@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key, resourceId: $domain.'.'.$key,
); );
return $setting; return $result['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
@ -174,6 +230,39 @@ 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,7 +17,6 @@ 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',
@ -28,7 +27,6 @@ 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

@ -18,6 +18,8 @@ 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';
@ -28,8 +30,6 @@ 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,6 +57,7 @@ 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,6 +48,7 @@ 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

@ -0,0 +1,26 @@
<?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

@ -0,0 +1,888 @@
<?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,8 +12,6 @@ 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,
@ -329,92 +327,6 @@ 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,7 +278,6 @@ 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),
]; ];
} }
@ -331,7 +330,6 @@ private static function operationAliases(): array
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,8 +4,10 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding; use App\Models\Finding;
use App\Services\Localization\LocaleResolver;
use App\Support\Ai\AiPolicyMode;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry final class SettingsRegistry
@ -28,6 +30,25 @@ 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',
@ -314,6 +335,44 @@ 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,23 +640,18 @@ 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 is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.', '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.',
'evidence' => [ 'evidence' => [
[ [
'kind' => 'feature_livewire_test', 'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php', 'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.', 'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
], ],
[ [
'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,
@ -749,12 +744,17 @@ 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 that exposes context and links, not a declaration-backed mutable system workbench.', '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.',
'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 without mutating actions.', 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
],
[
'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,6 +8,8 @@ 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();
@ -22,4 +24,37 @@ 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,6 +4,7 @@
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;
@ -24,7 +25,12 @@
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

@ -0,0 +1,25 @@
<?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,7 +41,6 @@ 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

@ -0,0 +1,88 @@
<?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

@ -0,0 +1,31 @@
<?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

@ -0,0 +1,230 @@
<?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

@ -0,0 +1,230 @@
<?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

@ -37,7 +37,7 @@
$compressedOutcome['primaryLabel'] ?? null, $compressedOutcome['primaryLabel'] ?? null,
$state['primaryLabel'] ?? null, $state['primaryLabel'] ?? null,
$operatorExplanation['headline'] ?? null, $operatorExplanation['headline'] ?? null,
'Artifact truth', __('localization.review.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,
'No action needed', __('localization.review.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' => 'Result meaning', 'label' => __('localization.review.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' => 'Result trust', 'label' => __('localization.review.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">
Diagnostics {{ __('localization.review.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'] ?? 'Count' }} {{ $count['label'] ?? __('localization.review.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">Next step</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">Guidance</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">Key entries</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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'] ?? 'Entry' }} {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.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">Follow-up</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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'] ?? 'Review explanation' }} {{ $operatorExplanation['headline'] ?? __('localization.review.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">Reason owner</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.reason_owner') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? __('localization.review.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">Platform reason family</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.platform_reason_family') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? __('localization.review.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">Highlights</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">Next actions</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">Related context</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">Publication readiness</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.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">
This review is ready for publication and executive-pack export. {{ __('localization.review.ready_for_publication') }}
</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>This review is currently safe for internal use only.</div> <div>{{ __('localization.review.internal_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 ?? 'This review still needs follow-up before publication.' }} {{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
</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">
Microsoft sign-in is not configured. {{ __('localization.auth.microsoft_not_configured') }}
</div> </div>
@endif @endif
@ -25,11 +25,11 @@
:disabled="! $isConfigured" :disabled="! $isConfigured"
color="primary" color="primary"
> >
Sign in with Microsoft {{ __('localization.auth.sign_in_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">
Tenant Admin access requires a tenant membership. {{ __('localization.auth.tenant_admin_membership_required') }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,164 @@
<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">
Customer-safe review workspace {{ __('localization.review.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">
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. {{ __('localization.review.customer_workspace_intro') }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
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. {{ __('localization.review.customer_workspace_canonical_note') }}
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -31,8 +31,8 @@
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? 'No tenant selected'; $tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected');
$workspaceLabel = $workspace?->name ?? 'Choose workspace'; $workspaceLabel = $workspace?->name ?? __('localization.shell.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,7 +40,8 @@
$workspaceUrl = $workspace $workspaceUrl = $workspace
? route('admin.home') ? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin'); : ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.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">
@ -63,7 +64,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 ? 'Tenant scope' : 'Select tenant' }}" aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.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' }}">
@ -78,12 +79,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">Context unavailable</div> <div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
@if ($workspace) @if ($workspace)
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div> <div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
@else @else
<div>Choose a workspace to continue with a valid admin context.</div> <div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
@endif @endif
</div> </div>
@endif @endif
@ -91,7 +92,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">
Workspace {{ __('localization.shell.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">
@ -104,7 +105,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"
> >
Switch workspace {{ __('localization.shell.switch_workspace') }}
</a> </a>
</div> </div>
@ -113,7 +114,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" />
Workspace Home {{ __('localization.shell.workspace_home') }}
</a> </a>
</div> </div>
@ -124,7 +125,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">
Selected tenant {{ __('localization.shell.selected_tenant') }}
</div> </div>
</div> </div>
@ -137,7 +138,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"
> >
Switch tenant {{ __('localization.shell.switch_tenant') }}
</a> </a>
</div> </div>
@ -146,7 +147,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">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -154,23 +155,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>No active tenants are available for the standard operating context in this workspace.</div> <div>{{ __('localization.shell.no_active_tenants') }}</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" />
View managed tenants {{ __('localization.shell.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">
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context. {{ __('localization.shell.workspace_wide_available') }}
</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="Search tenants…" placeholder="{{ __('localization.shell.search_tenants') }}"
x-model="query" x-model="query"
/> />
@ -207,7 +208,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">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -216,10 +217,12 @@ 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">
Choose a workspace first. {{ __('localization.shell.choose_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

@ -0,0 +1,110 @@
@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,9 +1,18 @@
@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'] ?? [];
@ -40,6 +49,63 @@
@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,13 +1,3 @@
@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>
@ -17,7 +7,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. Always run preflight first, and ensure you have the correct scope selected. Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
</p> </p>
</div> </div>
</div> </div>
@ -25,100 +15,17 @@
<x-filament::section> <x-filament::section>
<x-slot name="heading"> <x-slot name="heading">
Rebuild Findings Lifecycle No supported runbooks
</x-slot> </x-slot>
<x-slot name="description"> <x-slot name="description">
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings. Supported platform runbooks will appear here when they are part of current product truth.
</x-slot> </x-slot>
<x-slot name="afterHeader"> <div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-filament::badge color="info" size="sm"> <x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
{{ $this->findingsScopeLabel() }} There are no operator-run repair runbooks exposed on this surface.
</x-filament::badge>
</x-slot>
<div class="space-y-4">
@if ($findingsLastRun)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
</span>
@if ($findingsLastRunStatusSpec)
<x-filament::badge
:color="$findingsLastRunStatusSpec->color"
:icon="$findingsLastRunStatusSpec->icon"
size="sm"
>
{{ $findingsLastRunStatusSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRunOutcomeSpec)
<x-filament::badge
:color="$findingsLastRunOutcomeSpec->color"
:icon="$findingsLastRunOutcomeSpec->icon"
size="sm"
>
{{ $findingsLastRunOutcomeSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRun->initiator_name)
<span class="text-xs text-gray-500 dark:text-gray-400">
by {{ $findingsLastRun->initiator_name }}
</span>
@endif
</div>
@endif
@if (is_array($this->findingsPreflight))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
</p>
</div>
</x-filament::section>
</div>
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
Nothing to do for the current scope.
</div>
@endif
@else
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
</div>
@endif
</div> </div>
</x-filament::section> </x-filament::section>
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -11,6 +11,7 @@
/** @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 */
@ -33,6 +34,12 @@
</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,6 +4,7 @@
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;
@ -67,6 +68,21 @@
->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

@ -0,0 +1,20 @@
<?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

@ -1,31 +0,0 @@
<?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

@ -10,10 +10,14 @@
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;
@ -68,6 +72,23 @@ 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');
@ -207,6 +228,36 @@ function evidenceSnapshotHeaderActions(Testable $component): array
->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

@ -0,0 +1,16 @@
<?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

@ -1,21 +0,0 @@
<?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');
});

View File

@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('backfills legacy findings (ack → triaged, lifecycle fields, due_at from backfill time)', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
[$user, $tenant] = createUserWithTenant(role: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
'acknowledged_by_user_id' => (int) $user->getKey(),
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'sla_days' => null,
'due_at' => null,
'triaged_at' => null,
'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
]);
BackfillFindingLifecycleJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
$finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
->and($finding->times_seen)->toBe(1)
->and($finding->sla_days)->toBe(14)
->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00')
->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
CarbonImmutable::setTestNow();
});
it('computes drift recurrence keys and consolidates drift duplicates', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
[$user, $tenant] = createUserWithTenant(role: 'manager');
$scopeKey = hash('sha256', 'scope-drift-backfill-duplicate');
$evidence = [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_snapshot',
'changed_fields' => ['snapshot_hash'],
],
'baseline' => ['policy_id' => 'policy-dupe'],
'current' => ['policy_id' => 'policy-dupe'],
];
$open = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'subject_type' => 'policy',
'subject_external_id' => 'policy-dupe',
'status' => Finding::STATUS_NEW,
'recurrence_key' => null,
'evidence_jsonb' => $evidence,
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'sla_days' => null,
'due_at' => null,
'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
]);
$duplicate = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'subject_type' => 'policy',
'subject_external_id' => 'policy-dupe',
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
'recurrence_key' => null,
'evidence_jsonb' => $evidence,
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
]);
BackfillFindingLifecycleJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
$tenantId = (int) $tenant->getKey();
$expectedRecurrenceKey = hash(
'sha256',
sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'),
);
expect(Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('recurrence_key', $expectedRecurrenceKey)
->count())->toBe(1);
$open->refresh();
$duplicate->refresh();
expect($open->recurrence_key)->toBe($expectedRecurrenceKey)
->and($open->status)->toBe(Finding::STATUS_NEW);
expect($duplicate->recurrence_key)->toBeNull()
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
->and($duplicate->resolved_reason)->toBeNull()
->and($duplicate->resolved_at)->toBeNull()
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
CarbonImmutable::setTestNow();
});

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps canonical findings workflow behavior after removing the backfill runtime surfaces', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$service = app(FindingWorkflowService::class);
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [
'owner_user_id' => null,
'assignee_user_id' => null,
'sla_days' => 14,
'due_at' => now()->addDays(14),
]);
$triaged = $service->triage($finding, $tenant, $owner);
$assigned = $service->assign(
finding: $triaged,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $assignee->getKey(),
ownerUserId: (int) $owner->getKey(),
);
$inProgress = $service->startProgress($assigned, $tenant, $owner);
$resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED);
$riskAccepted = $service->riskAccept(
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
$tenant,
$owner,
Finding::CLOSE_REASON_ACCEPTED_RISK,
);
expect($triaged->status)->toBe(Finding::STATUS_TRIAGED)
->and($triaged->triaged_at)->not->toBeNull()
->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey())
->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey())
->and($assigned->sla_days)->toBe(14)
->and($assigned->due_at)->not->toBeNull()
->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS)
->and($inProgress->in_progress_at)->not->toBeNull()
->and($resolved->status)->toBe(Finding::STATUS_RESOLVED)
->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK);
expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull()
->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull()
->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull()
->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull()
->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull();
});

View File

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Support\Audit\AuditActionId;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps the findings backfill action visible but blocks execution when a control is active', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Workspace-specific pause.',
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle')
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id);
});
it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void {
Queue::fake();
[$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner');
[$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $blockedTenant->workspace_id,
'reason_text' => 'Paused only for the blocked workspace.',
]);
$this->actingAs($allowedUser);
Filament::setTenant($allowedTenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->where('tenant_id', (int) $allowedTenant->getKey())
->latest('id')
->first();
expect($run)->not->toBeNull();
Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool {
return $job->tenantId === (int) $allowedTenant->getKey()
&& $job->workspaceId === (int) $allowedTenant->workspace_id;
});
expect(AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->where('tenant_id', (int) $allowedTenant->getKey())
->exists())->toBeFalse();
});

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Models\Finding;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('removes the tenant findings lifecycle backfill header action without removing canonical workflow actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionDoesNotExist('backfill_lifecycle')
->assertActionExists('triage_all_matching')
->assertTableActionVisible('triage', $finding)
->assertTableActionVisible('assign', $finding)
->assertTableActionVisible('resolve', $finding)
->assertTableActionVisible('request_exception', $finding);
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse();
});

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use function Pest\Laravel\mock;
it('redirects governance inbox visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the governance inbox route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
mock(WorkspaceCapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')->andReturnFalse();
});
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertForbidden();
});
it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Governance inbox');
});
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('embeds canonical governance inbox navigation context into source links', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create();
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
backLinkLabel: 'Back to governance inbox',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'),
);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'));
$response->assertOk();
$expectedMyFindingsUrl = htmlspecialchars(
MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()),
ENT_QUOTES,
);
$expectedOperationUrl = htmlspecialchars(
OperationRunLinks::tenantlessView($run, $context),
ENT_QUOTES,
);
$response->assertSee($expectedMyFindingsUrl, false)
->assertSee($expectedOperationUrl, false)
->assertSee((string) $finding->getKey())
->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false);
});

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use App\Support\Workspaces\WorkspaceContext;
it('renders visible governance attention sections on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($alphaTenant)
->assignedTo((int) $user->getKey())
->ownedBy((int) $user->getKey())
->overdueByHours()
->create();
Finding::factory()
->for($bravoTenant)
->reopened()
->create();
OperationRun::factory()
->forTenant($alphaTenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
'payload' => [
'title' => 'Delivery failed',
'body' => 'A notification destination failed.',
],
]);
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$fingerprints = app(TenantTriageReviewFingerprint::class);
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
expect($alphaBackupFingerprint)->not->toBeNull();
TenantTriageReview::factory()
->for($alphaTenant)
->followUpNeeded()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertSee('Findings intake')
->assertSee('Operations follow-up')
->assertSee('Alert delivery failures')
->assertSee('Review follow-up')
->assertSee('Open my findings')
->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries')
->assertSee('Open review follow-up');
});
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($bravoTenant)
->assignedTo((int) $user->getKey())
->create();
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This tenant filter is hiding other visible attention')
->assertSee('Clear tenant filter');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
->assertOk()
->assertSee('Alert delivery failures')
->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings');
});

View File

@ -12,7 +12,6 @@ function livewireTrustedStateFirstSliceFixtures(): array
return [ return [
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php', TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php', TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php',
]; ];
} }

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Support\Workspaces\WorkspaceContext;
it('renders the admin auth surface in the explicit locale override', function (): void {
$this->withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de'])
->get('/admin/login')
->assertSuccessful()
->assertSee('Mit Microsoft anmelden')
->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft');
});
it('keeps system plane resolution independent from user and workspace preferences', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'de'])->save();
session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => null,
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT)
->assertJsonPath('user_preference_locale', null)
->assertJsonPath('workspace_default_locale', null);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
});

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('allows users to save and clear a personal locale preference over workspace default', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => 'en'])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBe('en');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => ''])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
});
it('allows temporary overrides to win until cleared', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'en'])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->delete(route('localization.override.clear'))
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull();
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('formats locale preference feedback in the resolved locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->post(route('localization.preference.update'), ['preferred_locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Spracheinstellung gespeichert.');
});
it('formats override feedback in the newly effective locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Sprachüberschreibung angewendet.');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'en',
])
->delete(route('localization.override.clear'))
->assertRedirect()
->assertSessionHas('status', 'Sprachüberschreibung gelöscht.');
});

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use Illuminate\Support\Facades\App;
it('keeps audit action identifiers and machine values invariant while UI locale is German', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
App::setLocale('de');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN)
->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE)
->and(data_get($audit->metadata, 'after_value'))->toBe('de');
});

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Lang;
it('falls back to English for missing German translation lines', function (): void {
Lang::addLines(['localization.fallback_probe' => 'English fallback probe'], 'en');
App::setFallbackLocale('en');
App::setLocale('de');
expect(__('localization.fallback_probe'))->toBe('English fallback probe');
});
it('does not expose raw translation keys for supported first-wave catalogs', function (): void {
App::setLocale('de');
expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft')
->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings')
->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported');
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\WorkspaceSetting;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver;
use Livewire\Livewire;
it('persists workspace default locale through the existing workspace settings page', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->set('data.localization_default_locale', 'de')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.localization_default_locale', 'de');
expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE))
->toBe('de');
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', LocaleResolver::SETTING_DOMAIN)
->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE)
->exists())->toBeTrue();
});
it('keeps workspace default locale authorization aligned to settings capabilities', function (): void {
[$workspace, $user] = localizationWorkspaceMember('readonly');
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->assertActionVisible('save')
->assertActionDisabled('save')
->call('save')
->assertStatus(403);
});

View File

@ -5,13 +5,16 @@
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
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\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -21,7 +24,12 @@
/** /**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable} * @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/ */
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array function readyOnboardingEntitlementContext(
int $activeTenantCount = 0,
?int $limitOverride = null,
?string $overrideReason = null,
?string $commercialState = null,
): array
{ {
Queue::fake(); Queue::fake();
@ -110,6 +118,22 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim
} }
} }
if ($commercialState !== null) {
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $workspace,
state: $commercialState,
reason: 'Onboarding entitlement test commercial state',
);
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [ $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
@ -188,3 +212,65 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE); expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
}); });
it('allows onboarding activation while a workspace is in trial', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Trial')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});
it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Grace')
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
it('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Suspended / read-only')
->assertSee('This workspace is suspended / read-only. New managed-tenant activation is blocked')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});

View File

@ -10,12 +10,12 @@
$checks = [ $checks = [
[ [
'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php', 'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php',
'required' => [ 'required' => [],
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'FindingsLifecycleBackfillScope::singleTenant(',
],
'forbidden' => [ 'forbidden' => [
'FindingsLifecycleBackfillRunbookService',
'FindingsLifecycleBackfillScope',
'Backfill findings lifecycle',
'backfill_lifecycle',
"config('tenantpilot.allow_admin_maintenance_actions'", "config('tenantpilot.allow_admin_maintenance_actions'",
'allow_admin_maintenance_actions', 'allow_admin_maintenance_actions',
'OperationalControlActivation::', 'OperationalControlActivation::',
@ -23,12 +23,12 @@
], ],
[ [
'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php', 'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php',
'required' => [ 'required' => [],
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'$runbookService->start(',
],
'forbidden' => [ 'forbidden' => [
'FindingsLifecycleBackfillRunbookService',
'FindingsLifecycleBackfillScope',
'findings.lifecycle.backfill',
'Rebuild Findings Lifecycle',
'OperationalControlActivation::', 'OperationalControlActivation::',
"config('tenantpilot.allow_admin_maintenance_actions'", "config('tenantpilot.allow_admin_maintenance_actions'",
], ],
@ -66,4 +66,16 @@
expect($source)->not->toContain($needle); expect($source)->not->toContain($needle);
} }
} }
foreach ([
$root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php',
$root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php',
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php',
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php',
$root.'/app/Jobs/BackfillFindingLifecycleJob.php',
$root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php',
$root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php',
] as $removedPath) {
expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}");
}
})->group('surface-guard'); })->group('surface-guard');

View File

@ -27,3 +27,10 @@
->toContain(".:/var/www/repo:ro") ->toContain(".:/var/www/repo:ro")
->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo'); ->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo');
}); });
it('keeps the local queue service in code-reloading listen mode', function (): void {
$compose = file_get_contents(repo_path('docker-compose.yml'));
expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3')
->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000');
});

View File

@ -4,8 +4,12 @@
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -38,6 +42,23 @@ function createReadyPackWithFile(?array $packOverrides = []): array
return [$user, $tenant, $pack]; return [$user, $tenant, $pack];
} }
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): 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: $pack->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Download preservation test',
);
}
// ─── Happy Path: Signed URL → 200 ─────────────────────────── // ─── Happy Path: Signed URL → 200 ───────────────────────────
it('downloads a ready pack via signed URL with correct headers', function (): void { it('downloads a ready pack via signed URL with correct headers', function (): void {
@ -64,6 +85,21 @@ function createReadyPackWithFile(?array $packOverrides = []): array
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
}); });
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
suspendReadyPackWorkspaceForDownloadTest($pack);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'suspended_read_only_check',
]);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk();
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
$response->assertDownload();
});
// ─── Expired Signature → 403 ──────────────────────────────── // ─── Expired Signature → 403 ────────────────────────────────
it('rejects requests with an expired signature', function (): void { it('rejects requests with an expired signature', function (): void {

View File

@ -8,16 +8,20 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
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\Models\User; use App\Models\User;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@ -108,6 +112,23 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
); );
} }
function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): 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: $state,
reason: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void { it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant); seedEntitlementReviewPackSnapshot($tenant);
@ -188,3 +209,86 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
->assertOk() ->assertOk()
->assertSee('Download'); ->assertSee('Download');
}); });
it('allows review pack generation in trial and active paid states', function (string $state): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, $state);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and($pack->operation_run_id)->not->toBeNull()
->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value);
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID],
]);
it('warns but allows review pack generation in grace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period');
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
expect($decision)
->toMatchArray([
'is_blocked' => false,
'is_warning' => true,
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
])
->and($decision['warning_reason'])->toContain('grace');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is in grace. Review-pack starts remain available');
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->exists())->toBeTrue();
});
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
Notification::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
Notification::assertNothingSent();
});
it('does not alter already queued review-pack work when a workspace is suspended later', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
$initialStatus = (string) $pack->fresh()?->status;
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension');
expect($pack->fresh()?->status)->toBe($initialStatus)
->and(OperationRun::query()
->whereKey((int) $pack->operation_run_id)
->exists())->toBeTrue();
});

View File

@ -3,18 +3,23 @@
declare(strict_types=1); declare(strict_types=1);
use App\Exceptions\ReviewPackEvidenceResolutionException; use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Widgets\Tenant\TenantReviewPackCard; use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Jobs\GenerateReviewPackJob; use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
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\Notifications\OperationRunCompleted; use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued; use App\Notifications\OperationRunQueued;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -157,6 +162,23 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
return $snapshot->load('items'); return $snapshot->load('items');
} }
function suspendReviewPackGenerationWorkspaceForGenerationTest(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: 'Generation notification boundary test',
);
}
// ─── Happy Path ────────────────────────────────────────────── // ─── Happy Path ──────────────────────────────────────────────
it('generates a review pack end-to-end (happy path)', function (): void { it('generates a review pack end-to-end (happy path)', function (): void {
@ -210,6 +232,22 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
Notification::assertSentTo($user, OperationRunCompleted::class); Notification::assertSentTo($user, OperationRunCompleted::class);
}); });
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
[$user, $tenant] = createUserWithTenant();
seedTenantWithData($tenant);
createEvidenceSnapshotForReviewPack($tenant);
suspendReviewPackGenerationWorkspaceForGenerationTest($tenant);
Notification::fake();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
Notification::assertNotSentTo($user, OperationRunQueued::class);
Notification::assertNotSentTo($user, OperationRunCompleted::class);
});
// ─── Failure Path ────────────────────────────────────────────── // ─── Failure Path ──────────────────────────────────────────────
it('marks pack as failed when generation throws an exception', function (): void { it('marks pack as failed when generation throws an exception', function (): void {

View File

@ -3,8 +3,12 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -12,6 +16,23 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
function suspendCustomerReviewWorkspacePackAccessWorkspace(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: 'Customer review workspace suspended read-only test',
);
}
it('shows the ready review-pack action for the latest published review', function (): void { it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
@ -48,6 +69,44 @@
->assertSee('Available'); ->assertSee('Available');
}); });
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
suspendCustomerReviewWorkspacePackAccessWorkspace($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationalControls\OperationalControlCatalog;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('does not expose findings lifecycle backfill in operational controls or the control catalog', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertDontSee('Findings lifecycle backfill')
->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false);
$catalog = app(OperationalControlCatalog::class);
expect($catalog->keys())->not->toContain('findings.lifecycle.backfill')
->and(fn (): array => $catalog->definition('findings.lifecycle.backfill'))
->toThrow(InvalidArgumentException::class);
});

View File

@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('does not crash when audit logging fails and still finalizes a failed run', function () {
$this->mock(AuditLogger::class, function ($mock): void {
$mock->shouldReceive('log')->andThrow(new RuntimeException('audit unavailable'));
});
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
$runbook = app(FindingsLifecycleBackfillRunbookService::class);
$run = $runbook->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: $user,
reason: null,
source: 'system_ui',
);
$runs = app(OperationRunService::class);
$runs->updateRun(
$run,
status: 'completed',
outcome: 'failed',
failures: [
[
'code' => 'test.failed',
'message' => 'Forced failure for audit fail-safe test.',
],
],
);
$runbook->maybeFinalize($run);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('failed');
});

View File

@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Dashboard;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
config()->set('tenantpilot.break_glass.enabled', true);
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
});
it('requires a reason when break-glass is active and records break-glass on the run + audit', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$customerTenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $customerTenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
PlatformCapabilities::USE_BREAK_GLASS,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Dashboard::class)
->callAction('enter_break_glass', data: [
'reason' => 'Recovery test',
])
->assertHasNoActionErrors();
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $customerTenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertHasActionErrors(['reason_code', 'reason_text']);
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $customerTenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'reason_code' => 'INCIDENT',
'reason_text' => 'Break-glass backfill required',
])
->assertHasNoActionErrors()
->assertNotified();
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect((int) $run?->tenant_id)->toBe((int) $customerTenant->getKey());
expect(data_get($run?->context, 'platform_initiator.is_break_glass'))->toBeTrue();
expect(data_get($run?->context, 'reason.reason_code'))->toBe('INCIDENT');
expect(data_get($run?->context, 'reason.reason_text'))->toBe('Break-glass backfill required');
$audit = AuditLog::query()
->where('action', 'platform.ops.runbooks.start')
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit?->metadata['is_break_glass'] ?? null)->toBe(true);
expect($audit?->metadata['reason_code'] ?? null)->toBe('INCIDENT');
expect($audit?->metadata['reason_text'] ?? null)->toBe('Break-glass backfill required');
});

View File

@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('is idempotent: after a successful run, preflight reports nothing to do', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$runbook = app(FindingsLifecycleBackfillRunbookService::class);
$initial = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
expect($initial['affected_count'])->toBe(1);
$runbook->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: null,
reason: null,
source: 'system_ui',
);
$job = new BackfillFindingLifecycleJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: null,
);
$job->handle(
app(OperationRunService::class),
app(\App\Services\Findings\FindingSlaPolicy::class),
$runbook,
);
$after = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
expect($after['affected_count'])->toBe(0);
expect(fn () => $runbook->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: null,
reason: null,
source: 'system_ui',
))->toThrow(ValidationException::class);
});

View File

@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('computes single-tenant preflight counts', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$service = app(FindingsLifecycleBackfillRunbookService::class);
$result = $service->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
expect($result['total_count'])->toBe(2);
expect($result['affected_count'])->toBe(1);
});
it('computes all-tenants preflight counts scoped to the platform workspace', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenantA = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
$otherTenant = Tenant::factory()->create();
Finding::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'due_at' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'sla_days' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $otherTenant->getKey(),
'due_at' => null,
]);
$service = app(FindingsLifecycleBackfillRunbookService::class);
$result = $service->preflight(FindingsLifecycleBackfillScope::allTenants());
expect($result['estimated_tenants'])->toBe(2);
expect($result['total_count'])->toBe(2);
expect($result['affected_count'])->toBe(2);
});
it('accepts an allowed single-tenant selection during preflight', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsTenantId', (int) $tenant->getKey())
->assertSet('preflight.affected_count', 1);
});
it('rejects platform tenant selection during preflight', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
assertScopedSelectorRejected(
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class),
'preflight',
[
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $platformTenant->getKey(),
],
);
});
it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenantA = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant A',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant B',
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'due_at' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $tenantA->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $tenantA->getKey())
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
'tenant_id' => (int) $tenantA->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('findingsTenantId', null)
->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('tenantId', null)
->assertSet('preflight.estimated_tenants', 2)
->assertSet('preflight.affected_count', 2);
});

View File

@ -1,253 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('disables running when preflight indicates nothing to do', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
])
->assertSet('preflight.affected_count', 0)
->assertActionDisabled('run');
});
it('requires typed confirmation and a reason for all-tenants runs', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertHasActionErrors([
'typed_confirmation',
'reason_code',
'reason_text',
]);
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'typed_confirmation' => 'backfill',
'reason_code' => 'DATA_REPAIR',
'reason_text' => 'Test run',
])
->assertHasActionErrors(['typed_confirmation']);
});
it('rejects forged single-tenant selector state on run and records no run or start audit', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$allowedTenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $allowedTenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $platformTenant->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $platformTenant->getKey())
->callAction('run', data: [])
->assertHasActionErrors();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});
it('records a start audit with the canonical single-tenant scope when an allowed run is queued', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertHasNoActionErrors()
->assertNotified('Findings lifecycle backfill queued');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->latest('id')
->first();
expect($run)->not->toBeNull();
$audit = AuditLog::query()
->where('action', 'platform.ops.runbooks.start')
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_id)->toBe((string) $run?->getKey())
->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey())
->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey());
});
it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertForbidden();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});

View File

@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'findings.lifecycle.backfill',
'reason_text' => 'Paused during incident response.',
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => 'all_tenants',
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'typed_confirmation' => 'BACKFILL',
'reason_code' => 'DATA_REPAIR',
'reason_text' => 'Attempt blocked by control',
])
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBeNull()
->and($audit?->tenant_id)->toBeNull()
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants');
});

View File

@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemOperationRunLinks;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('uses an intent-only toast with a working view-run link and does not emit queued database notifications', function () {
Queue::fake();
NotificationFacade::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'typed_confirmation' => 'BACKFILL',
'reason_code' => 'DATA_REPAIR',
'reason_text' => 'Operator test',
])
->assertHasNoActionErrors()
->assertNotified('Findings lifecycle backfill queued');
NotificationFacade::assertNothingSent();
expect(DatabaseNotification::query()->count())->toBe(0);
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->latest('id')
->first();
expect($run)->not->toBeNull();
$viewUrl = SystemOperationRunLinks::view($run);
$this->get($viewUrl)
->assertSuccessful()
->assertSee('Operation #'.(int) $run?->getKey());
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('keeps the system runbooks page accessible without findings lifecycle backfill launch surfaces', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
$this->get(Runbooks::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee('No supported runbooks')
->assertDontSee('Rebuild Findings Lifecycle')
->assertDontSee('Backfills legacy findings lifecycle fields')
->assertDontSee('Preflight')
->assertDontSee('preflight')
->assertDontSee('Run: Rebuild Findings Lifecycle')
->assertDontSee('BACKFILL');
Livewire::test(Runbooks::class)
->assertActionDoesNotExist('preflight')
->assertActionDoesNotExist('run');
});
it('preserves runbooks view authorization semantics after removing the backfill runbook', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Runbooks::getUrl(panel: 'system'))
->assertForbidden();
});

View File

@ -5,7 +5,9 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -119,3 +121,38 @@
->get('/system/ops/runbooks') ->get('/system/ops/runbooks')
->assertSuccessful(); ->assertSuccessful();
}); });
it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void {
$workspace = Workspace::factory()->create();
$this->actingAs(User::factory()->create())
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertNotFound();
auth()->guard('web')->logout();
$platformWithoutDirectoryView = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($platformWithoutDirectoryView, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertForbidden();
$directoryViewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($directoryViewer, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertSuccessful()
->assertSee('Commercial lifecycle')
->assertDontSee('Change commercial state');
});

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