Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
ca4b9b6138 feat: upgrade Filament to v5.2.1 2026-02-20 12:45:02 +01:00
455 changed files with 2440 additions and 40131 deletions

View File

@ -31,14 +31,6 @@ ## Active Technologies
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness) - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions) - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1) - PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -58,8 +50,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 - 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 - 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -1,15 +1,11 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.9.0 → 1.10.0 - Version change: 1.8.2 → 1.9.0
- Modified principles: - Modified principles:
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy) - Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
- Added sections: - Added sections:
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE) - Filament UI — Layout & Information Architecture Standards (UX-001)
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
- Summary counts contract (OPS-UX-SUM-001)
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
- Scheduled/system runs (OPS-UX-SYS-001)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
@ -162,72 +158,6 @@ ### Operations / Run Observability Standard
- Monitoring pages MUST be DB-only at render time (no external calls). - Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work, - Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
confirm + “View run”. confirm + “View run”.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
2) Progress (active awareness only)
- Live progress MUST exist only in:
- the global active-ops widget, and
- Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
3) Terminal DB Notification (audit outcome only)
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
- Delivery MUST be initiator-only (no tenant-wide fan-out).
- Completion notifications MUST be `OperationRunCompleted` only.
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
Canonical navigation:
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
Forbidden outside `OperationRunService`:
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
- Query-based updates that transition `status`/`outcome`
Allowed outside the service:
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
### Summary counts contract (OPS-UX-SUM-001)
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
- Producers MUST NOT introduce new keys without:
1) updating `OperationSummaryKeys::all()`,
2) updating the spec canonical list,
3) adding/adjusting tests.
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again.
These guards MUST fail with actionable output (file + snippet).
### Scheduled/system runs (OPS-UX-SYS-001)
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states). - Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps - Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
in failures or notifications. in failures or notifications.
@ -344,4 +274,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23 **Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19

View File

@ -41,11 +41,6 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) - RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked - Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` - Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests

View File

@ -94,13 +94,6 @@ ## Requirements *(mandatory)*
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. (preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: **Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`), - state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404), - ensure any cross-plane access is deny-as-not-found (404),

View File

@ -14,13 +14,6 @@ # Tasks: [FEATURE NAME]
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant). If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`. without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
**RBAC**: If this feature introduces or changes authorization, tasks MUST include: **RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions, - explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics: - explicit 404 vs 403 semantics:

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class GraphContractCheck extends Command class GraphContractCheck extends Command
@ -12,7 +11,7 @@ class GraphContractCheck extends Command
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)'; protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int public function handle(GraphClientInterface $graph): int
{ {
$contracts = config('graph_contracts.types', []); $contracts = config('graph_contracts.types', []);
@ -37,13 +36,11 @@ public function handle(GraphClientInterface $graph, GraphContractRegistry $regis
continue; continue;
} }
$queryInput = array_filter([ $query = array_filter([
'$top' => 1, '$top' => 1,
'$select' => $select, '$select' => $select,
'$expand' => $expand, '$expand' => $expand,
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []); ]);
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
$response = $graph->request('GET', $resource, [ $response = $graph->request('GET', $resource, [
'query' => $query, 'query' => $query,

View File

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ReviewPack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class PruneReviewPacksCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
/**
* @var string
*/
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
public function handle(): int
{
$expired = $this->expireReadyPacks();
$hardDeleted = 0;
if ($this->option('hard-delete')) {
$hardDeleted = $this->hardDeleteExpiredPacks();
}
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
return self::SUCCESS;
}
/**
* Transition ready packs past retention to expired and delete their files.
*/
private function expireReadyPacks(): int
{
$packs = ReviewPack::query()
->ready()
->pastRetention()
->get();
$disk = Storage::disk('exports');
$count = 0;
foreach ($packs as $pack) {
/** @var ReviewPack $pack */
if ($pack->file_path && $disk->exists($pack->file_path)) {
$disk->delete($pack->file_path);
}
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
$count++;
}
return $count;
}
/**
* Hard-delete expired packs that are past the grace period.
*/
private function hardDeleteExpiredPacks(): int
{
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
$cutoff = now()->subDays($graceDays);
return ReviewPack::query()
->expired()
->where('updated_at', '<', $cutoff)
->delete();
}
}

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\StoredReport;
use Illuminate\Console\Command;
class PruneStoredReportsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'stored-reports:prune {--days= : Number of days to retain reports}';
/**
* @var string
*/
protected $description = 'Delete stored reports older than the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.stored_reports.retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = StoredReport::query()
->where('created_at', '<', $cutoff)
->delete();
$this->info("Deleted {$deleted} stored report(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -1,120 +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 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 (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,51 +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 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 (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

@ -1,23 +0,0 @@
<?php
namespace App\Contracts\Hardening;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
interface WriteGateInterface
{
/**
* Evaluate whether a write operation is allowed for the given tenant.
*
* @throws ProviderAccessHardeningRequired when the operation is blocked
*/
public function evaluate(Tenant $tenant, string $operationType): void;
/**
* Check whether the gate would block a write operation for the given tenant.
*
* Non-throwing variant for UI disabled-state checks.
*/
public function wouldBlock(Tenant $tenant): bool;
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Exceptions\Hardening;
use RuntimeException;
class ProviderAccessHardeningRequired extends RuntimeException
{
public function __construct(
public readonly int $tenantId,
public readonly string $operationType,
public readonly string $reasonCode,
public readonly string $reasonMessage,
) {
parent::__construct($reasonMessage);
}
}

View File

@ -7,15 +7,10 @@
use BackedEnum; use BackedEnum;
use Filament\Clusters\Cluster; use Filament\Clusters\Cluster;
use Filament\Pages\Enums\SubNavigationPosition; use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class InventoryCluster extends Cluster class InventoryCluster extends Cluster
{ {
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start; protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Items';
} }

View File

@ -14,8 +14,6 @@
use App\Services\Baselines\BaselineCompareService; use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
@ -245,14 +243,10 @@ private function compareNowAction(): Action
$this->state = 'comparing'; $this->state = 'comparing';
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Baseline comparison started')
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare') ->body('A background job will compute drift against the baseline snapshot.')
->actions($run instanceof OperationRun ? [ ->success()
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $tenant)),
] : [])
->send(); ->send();
}); });
} }

View File

@ -27,17 +27,6 @@ class ChooseTenant extends Page
protected string $view = 'filament.pages.choose-tenant'; protected string $view = 'filament.pages.choose-tenant';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
/** /**
* @return Collection<int, Tenant> * @return Collection<int, Tenant>
*/ */

View File

@ -7,11 +7,10 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl; use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver; use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -31,18 +30,33 @@ class ChooseWorkspace extends Page
protected string $view = 'filament.pages.choose-workspace'; protected string $view = 'filament.pages.choose-workspace';
/** /**
* Workspace roles keyed by workspace_id. * @return array<Action>
*
* @var array<int, string>
*/
public array $workspaceRoles = [];
/**
* @return array<\Filament\Actions\Action>
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->visible(function (): bool {
$user = auth()->user();
return $user instanceof User
&& $user->can('create', Workspace::class);
})
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
} }
/** /**
@ -56,28 +70,15 @@ public function getWorkspaces(): Collection
return Workspace::query()->whereRaw('1 = 0')->get(); return Workspace::query()->whereRaw('1 = 0')->get();
} }
$workspaces = Workspace::query() return Workspace::query()
->whereIn('id', function ($query) use ($user): void { ->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships') $query->from('workspace_memberships')
->select('workspace_id') ->select('workspace_id')
->where('user_id', $user->getKey()); ->where('user_id', $user->getKey());
}) })
->whereNull('archived_at') ->whereNull('archived_at')
->withCount(['tenants' => function ($query): void {
$query->where('status', 'active');
}])
->orderBy('name') ->orderBy('name')
->get(); ->get();
// Build roles map from memberships.
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->whereIn('workspace_id', $workspaces->pluck('id'))
->pluck('role', 'workspace_id');
$this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all();
return $workspaces;
} }
public function selectWorkspace(int $workspaceId): void public function selectWorkspace(int $workspaceId): void
@ -104,35 +105,11 @@ public function selectWorkspace(int $workspaceId): void
abort(404); abort(404);
} }
$prevWorkspaceId = $context->currentWorkspaceId(request());
$context->setCurrentWorkspace($workspace, $user, request()); $context->setCurrentWorkspace($workspace, $user, request());
// Audit: manual workspace selection.
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSelected->value,
context: [
'metadata' => [
'method' => 'manual',
'reason' => 'chooser',
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume(request()); $intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
$resolver = app(WorkspaceRedirectResolver::class);
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
} }
/** /**
@ -170,9 +147,41 @@ public function createWorkspace(array $data): void
$intendedUrl = WorkspaceIntendedUrl::consume(request()); $intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
$resolver = app(WorkspaceRedirectResolver::class); }
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); private function redirectAfterWorkspaceSelected(User $user): string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return self::getUrl();
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
}
}
return ChooseTenant::getUrl();
} }
} }

View File

@ -20,6 +20,7 @@
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use UnitEnum; use UnitEnum;
@ -239,8 +240,10 @@ public function mount(): void
$this->state = 'generating'; $this->state = 'generating';
if (! $opRun->wasRecentlyCreated) { if (! $opRun->wasRecentlyCreated) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Drift generation already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -6,7 +6,6 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
@ -19,13 +18,12 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
@ -53,32 +51,10 @@ class WorkspaceSettings extends Page
'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'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'], 'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'], 'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
]; ];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
* @var array<int, string>
*/
private const KEYVALUE_FIELDS = [
'drift_severity_mapping',
];
/**
* Findings SLA days are decomposed into individual form fields per severity.
*
* @var array<string, string>
*/
private const SLA_SUB_FIELDS = [
'findings_sla_critical' => 'critical',
'findings_sla_high' => 'high',
'findings_sla_medium' => 'medium',
'findings_sla_low' => 'low',
];
public Workspace $workspace; public Workspace $workspace;
/** /**
@ -96,13 +72,6 @@ class WorkspaceSettings extends Page
*/ */
public array $resolvedSettings = []; public array $resolvedSettings = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
* @var array<string, array{user_name: string, updated_at: Carbon}>
*/
public array $domainLastModified = [];
/** /**
* @return array<Action> * @return array<Action>
*/ */
@ -166,13 +135,11 @@ public function content(Schema $schema): Schema
->statePath('data') ->statePath('data')
->schema([ ->schema([
Section::make('Backup settings') Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.')) ->description('Workspace defaults used when a schedule has no explicit value.')
->schema([ ->schema([
TextInput::make('backup_retention_keep_last_default') TextInput::make('backup_retention_keep_last_default')
->label('Default retention keep-last') ->label('Default retention keep-last')
->placeholder('Unset (uses default)') ->placeholder('Unset (uses default)')
->suffix('versions')
->hint('1 365')
->numeric() ->numeric()
->integer() ->integer()
->minValue(1) ->minValue(1)
@ -183,8 +150,6 @@ public function content(Schema $schema): Schema
TextInput::make('backup_retention_min_floor') TextInput::make('backup_retention_min_floor')
->label('Minimum retention floor') ->label('Minimum retention floor')
->placeholder('Unset (uses default)') ->placeholder('Unset (uses default)')
->suffix('versions')
->hint('1 365')
->numeric() ->numeric()
->integer() ->integer()
->minValue(1) ->minValue(1)
@ -194,79 +159,22 @@ public function content(Schema $schema): Schema
->hintAction($this->makeResetAction('backup_retention_min_floor')), ->hintAction($this->makeResetAction('backup_retention_min_floor')),
]), ]),
Section::make('Drift settings') Section::make('Drift settings')
->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.')) ->description('Map finding types to severities as JSON.')
->schema([ ->schema([
KeyValue::make('drift_severity_mapping') Textarea::make('drift_severity_mapping')
->label('Severity mapping') ->label('Severity mapping (JSON object)')
->keyLabel('Finding type') ->rows(8)
->valueLabel('Severity') ->placeholder("{\n \"drift\": \"critical\"\n}")
->keyPlaceholder('e.g. drift')
->valuePlaceholder('critical, high, medium, or low')
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping')) ->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
->hintAction($this->makeResetAction('drift_severity_mapping')), ->hintAction($this->makeResetAction('drift_severity_mapping')),
]), ]),
Section::make('Findings settings')
->key('findings_section')
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
->columns(2)
->afterHeader([
$this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'),
])
->schema([
TextInput::make('findings_sla_critical')
->label('Critical severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('critical')),
TextInput::make('findings_sla_high')
->label('High severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('high')),
TextInput::make('findings_sla_medium')
->label('Medium severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('medium')),
TextInput::make('findings_sla_low')
->label('Low severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('low')),
]),
Section::make('Operations settings') Section::make('Operations settings')
->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.')) ->description('Workspace controls for operations retention and thresholds.')
->schema([ ->schema([
TextInput::make('operations_operation_run_retention_days') TextInput::make('operations_operation_run_retention_days')
->label('Operation run retention') ->label('Operation run retention (days)')
->placeholder('Unset (uses default)') ->placeholder('Unset (uses default)')
->suffix('days')
->hint('7 3,650')
->numeric() ->numeric()
->integer() ->integer()
->minValue(7) ->minValue(7)
@ -275,10 +183,8 @@ public function content(Schema $schema): Schema
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days')) ->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
->hintAction($this->makeResetAction('operations_operation_run_retention_days')), ->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
TextInput::make('operations_stuck_run_threshold_minutes') TextInput::make('operations_stuck_run_threshold_minutes')
->label('Stuck run threshold') ->label('Stuck run threshold (minutes)')
->placeholder('Unset (uses default)') ->placeholder('Unset (uses default)')
->suffix('minutes')
->hint('0 10,080')
->numeric() ->numeric()
->integer() ->integer()
->minValue(0) ->minValue(0)
@ -300,10 +206,6 @@ public function save(): void
$this->authorizeWorkspaceManage($user); $this->authorizeWorkspaceManage($user);
$this->resetValidation();
$this->composeSlaSubFieldsIntoData();
[$normalizedValues, $validationErrors] = $this->normalizedInputValues(); [$normalizedValues, $validationErrors] = $this->normalizedInputValues();
if ($validationErrors !== []) { if ($validationErrors !== []) {
@ -418,79 +320,13 @@ private function loadFormState(): void
]; ];
$data[$field] = $workspaceValue === null $data[$field] = $workspaceValue === null
? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null) ? null
: $this->formatValueForInput($field, $workspaceValue); : $this->formatValueForInput($field, $workspaceValue);
} }
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
$this->data = $data; $this->data = $data;
$this->workspaceOverrides = $workspaceOverrides; $this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings; $this->resolvedSettings = $resolvedSettings;
$this->loadDomainLastModified();
}
/**
* Load per-domain "last modified" metadata from workspace_settings.
*/
private function loadDomainLastModified(): void
{
$domains = array_unique(array_column(self::SETTING_FIELDS, 'domain'));
$records = WorkspaceSetting::query()
->where('workspace_id', (int) $this->workspace->getKey())
->whereIn('domain', $domains)
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->get();
$domainInfo = [];
foreach ($records as $record) {
/** @var WorkspaceSetting $record */
$domain = $record->domain;
$updatedAt = $record->updated_at;
if (! $updatedAt instanceof Carbon) {
continue;
}
if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) {
continue;
}
$user = $record->updatedByUser;
$domainInfo[$domain] = [
'user_name' => $user instanceof User ? $user->name : 'Unknown',
'updated_at' => $updatedAt,
];
}
$this->domainLastModified = $domainInfo;
}
/**
* Build a section description that appends "last modified" info when available.
*/
private function sectionDescription(string $domain, string $baseDescription): string
{
$meta = $this->domainLastModified[$domain] ?? null;
if (! is_array($meta)) {
return $baseDescription;
}
/** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at'];
return sprintf(
'%s — Last modified by %s, %s.',
$baseDescription,
$meta['user_name'],
$updatedAt->diffForHumans(),
);
} }
private function makeResetAction(string $field): Action private function makeResetAction(string $field): Action
@ -537,29 +373,6 @@ private function helperTextFor(string $field): string
return sprintf('Effective value: %s.', $effectiveValue); return sprintf('Effective value: %s.', $effectiveValue);
} }
private function slaFieldHelperText(string $severity): string
{
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveValue = is_array($resolved['value'] ?? null)
? (int) ($resolved['value'][$severity] ?? 0)
: 0;
$systemDefault = is_array($resolved['system_default'] ?? null)
? (int) ($resolved['system_default'][$severity] ?? 0)
: 0;
if (! $this->hasWorkspaceOverride('findings_sla_days')) {
return sprintf('Default: %d days.', $systemDefault);
}
return sprintf('Effective: %d days.', $effectiveValue);
}
/** /**
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>} * @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
*/ */
@ -583,35 +396,6 @@ private function normalizedInputValues(): array
} }
} }
if ($field === 'findings_sla_days') {
$severityToField = array_flip(self::SLA_SUB_FIELDS);
$targeted = false;
foreach ($messages as $message) {
if (preg_match('/include "(?<severity>critical|high|medium|low)"/i', $message, $matches) === 1) {
$severity = strtolower((string) $matches['severity']);
$subField = $severityToField[$severity] ?? null;
if (is_string($subField)) {
$validationErrors['data.'.$subField] ??= [];
$validationErrors['data.'.$subField][] = $message;
$targeted = true;
}
}
}
if (! $targeted) {
foreach (self::SLA_SUB_FIELDS as $subField => $_severity) {
$validationErrors['data.'.$subField] = $messages !== []
? $messages
: ['Invalid value.'];
}
}
continue;
}
$validationErrors['data.'.$field] = $messages !== [] $validationErrors['data.'.$field] = $messages !== []
? $messages ? $messages
: ['Invalid value.']; : ['Invalid value.'];
@ -633,20 +417,8 @@ private function normalizeFieldInput(string $field, mixed $value): mixed
return null; return null;
} }
if (is_array($value) && $value === []) {
return null;
}
if ($setting['type'] === 'json') { if ($setting['type'] === 'json') {
$value = $this->normalizeJsonInput($value); $value = $this->normalizeJsonInput($value);
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
$value = $this->normalizeKeyValueInput($value);
if ($value === []) {
return null;
}
}
} }
$definition = $this->settingDefinition($field); $definition = $this->settingDefinition($field);
@ -663,87 +435,6 @@ private function normalizeFieldInput(string $field, mixed $value): mixed
return $definition->normalize($validator->validated()['value']); return $definition->normalize($validator->validated()['value']);
} }
/**
* Normalize KeyValue component state.
*
* Filament's KeyValue UI keeps an empty row by default, which can submit as
* ['' => ''] and would otherwise fail validation. We treat empty rows as unset.
*
* @param array<mixed> $value
* @return array<string, mixed>
*/
private function normalizeKeyValueInput(array $value): array
{
$normalized = [];
foreach ($value as $key => $item) {
if (is_array($item) && array_key_exists('key', $item)) {
$rowKey = $item['key'];
$rowValue = $item['value'] ?? null;
if (! is_string($rowKey)) {
continue;
}
$trimmedKey = trim($rowKey);
if ($trimmedKey === '') {
continue;
}
if (is_string($rowValue)) {
$trimmedValue = trim($rowValue);
if ($trimmedValue === '') {
continue;
}
$normalized[$trimmedKey] = $trimmedValue;
continue;
}
if ($rowValue === null) {
continue;
}
$normalized[$trimmedKey] = $rowValue;
continue;
}
if (! is_string($key)) {
continue;
}
$trimmedKey = trim($key);
if ($trimmedKey === '') {
continue;
}
if (is_string($item)) {
$trimmedValue = trim($item);
if ($trimmedValue === '') {
continue;
}
$normalized[$trimmedKey] = $trimmedValue;
continue;
}
if ($item === null) {
continue;
}
$normalized[$trimmedKey] = $item;
}
return $normalized;
}
private function normalizeJsonInput(mixed $value): array private function normalizeJsonInput(mixed $value): array
{ {
if (is_array($value)) { if (is_array($value)) {
@ -825,10 +516,6 @@ private function formatValueForInput(string $field, mixed $value): mixed
return null; return null;
} }
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
return $value;
}
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null; return is_string($encoded) ? $encoded : null;
@ -891,46 +578,14 @@ private function hasWorkspaceOverride(string $field): bool
private function workspaceOverrideForField(string $field): mixed private function workspaceOverrideForField(string $field): mixed
{ {
return $this->workspaceOverrides[$field] ?? null; $setting = $this->settingForField($field);
} $resolved = app(SettingsResolver::class)->resolveDetailed(
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
/** return $resolved['workspace_value'];
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $workspaceOverrides
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $resolvedSettings
*/
private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void
{
$slaOverride = $workspaceOverrides['findings_sla_days'] ?? null;
$slaResolved = $resolvedSettings['findings_sla_days'] ?? null;
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
$data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity])
? (int) $slaOverride[$severity]
: null;
}
}
/**
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
*/
private function composeSlaSubFieldsIntoData(): void
{
$values = [];
$hasAnyValue = false;
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
$val = $this->data[$subField] ?? null;
if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) {
$values[$severity] = (int) $val;
$hasAnyValue = true;
}
}
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
} }
private function currentUserCanManage(): bool private function currentUserCanManage(): bool

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
@ -170,12 +169,6 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): string public function reRunVerificationUrl(): string
{ {
$tenant = $this->scopedTenant;
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
}
return route('admin.onboarding'); return route('admin.onboarding');
} }

View File

@ -33,8 +33,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Verification\VerificationCheckStatus; use App\Support\Verification\VerificationCheckStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -77,18 +75,6 @@ class ManagedTenantOnboardingWizard extends Page
protected static ?string $slug = 'onboarding'; protected static ?string $slug = 'onboarding';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s
* on this workspace-scoped route.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public Workspace $workspace; public Workspace $workspace;
public ?Tenant $managedTenant = null; public ?Tenant $managedTenant = null;
@ -1446,8 +1432,6 @@ public function startVerification(): void
); );
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Another operation is already running')
->body('Please wait for the active run to finish.') ->body('Please wait for the active run to finish.')
@ -1505,27 +1489,23 @@ public function startVerification(): void
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); $notification = Notification::make()
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
if ($result->status === 'deduped') {
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('View run')
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return;
}
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
]) ]);
->send();
if ($result->status === 'deduped') {
$notification
->body('A verification run is already queued or running.')
->warning();
} else {
$notification->success();
}
$notification->send();
} }
public function refreshVerificationStatus(): void public function refreshVerificationStatus(): void
@ -1617,7 +1597,7 @@ public function startBootstrap(array $operationTypes): void
return; return;
} }
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|array{status: 'scope_busy', run: OperationRun} $result */ /** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
$lockedConnection = ProviderConnection::query() $lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey()) ->whereKey($connection->getKey())
@ -1641,7 +1621,6 @@ public function startBootstrap(array $operationTypes): void
$runsService = app(OperationRunService::class); $runsService = app(OperationRunService::class);
$bootstrapRuns = []; $bootstrapRuns = [];
$bootstrapCreated = [];
foreach ($types as $operationType) { foreach ($types as $operationType) {
$definition = $registry->get($operationType); $definition = $registry->get($operationType);
@ -1680,19 +1659,15 @@ public function startBootstrap(array $operationTypes): void
} }
$bootstrapRuns[$operationType] = (int) $run->getKey(); $bootstrapRuns[$operationType] = (int) $run->getKey();
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
} }
return [ return [
'status' => 'started', 'status' => 'started',
'runs' => $bootstrapRuns, 'runs' => $bootstrapRuns,
'created' => $bootstrapCreated,
]; ];
}); });
if ($result['status'] === 'scope_busy') { if ($result['status'] === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Another operation is already running')
->body('Please wait for the active run to finish.') ->body('Please wait for the active run to finish.')
@ -1724,27 +1699,10 @@ public function startBootstrap(array $operationTypes): void
$this->onboardingSession->save(); $this->onboardingSession->save();
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Bootstrap started')
foreach ($types as $operationType) { ->success()
$runId = (int) ($bootstrapRuns[$operationType] ?? 0); ->send();
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
$toast = $wasCreated
? OperationUxPresenter::queuedToast($operationType)
: OperationUxPresenter::alreadyQueuedToast($operationType);
if ($runUrl !== null) {
$toast->actions([
Action::make('view_run')
->label('View run')
->url($runUrl),
]);
}
$toast->send();
}
} }
private function dispatchBootstrapJob( private function dispatchBootstrapJob(

View File

@ -18,26 +18,12 @@ class ManagedTenantsLanding extends Page
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static string $layout = 'filament-panels::components.layout.simple';
protected static ?string $title = 'Managed tenants'; protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing'; protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace; public Workspace $workspace;
/**
* The Filament simple layout renders the topbar by default, which includes
* lazy-loaded database notifications. On this workspace-scoped landing page,
* those background Livewire requests currently 404.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public function mount(Workspace $workspace): void public function mount(Workspace $workspace): void
{ {
$this->workspace = $workspace; $this->workspace = $workspace;

View File

@ -5,6 +5,7 @@
namespace App\Filament\Resources\AlertDestinationResource\Pages; namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertDestinationResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -15,6 +16,10 @@ class ListAlertDestinations extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
...app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
),
CreateAction::make() CreateAction::make()
->label('Create target') ->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()), ->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),

View File

@ -28,7 +28,6 @@
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -145,76 +144,64 @@ public static function form(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Rule') TextInput::make('name')
->schema([ ->required()
TextInput::make('name') ->maxLength(255),
->required() Toggle::make('is_enabled')
->maxLength(255), ->label('Enabled')
Toggle::make('is_enabled') ->default(true),
->label('Enabled') Select::make('event_type')
->default(true), ->required()
Select::make('event_type') ->options(self::eventTypeOptions())
->required() ->native(false),
->options(self::eventTypeOptions()) Select::make('minimum_severity')
->native(false), ->required()
Select::make('minimum_severity') ->options(self::severityOptions())
->required() ->native(false),
->options(self::severityOptions()) Select::make('tenant_scope_mode')
->native(false), ->required()
]), ->options([
Section::make('Applies to') AlertRule::TENANT_SCOPE_ALL => 'All tenants',
->schema([ AlertRule::TENANT_SCOPE_ALLOWLIST => 'Allowlist',
Select::make('tenant_scope_mode') ])
->label('Applies to tenants') ->default(AlertRule::TENANT_SCOPE_ALL)
->required() ->native(false)
->options([ ->live(),
AlertRule::TENANT_SCOPE_ALL => 'All tenants', Select::make('tenant_allowlist')
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants', ->label('Tenant allowlist')
]) ->multiple()
->default(AlertRule::TENANT_SCOPE_ALL) ->options(self::tenantOptions())
->native(false) ->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
->live() ->native(false),
->helperText('This rule is workspace-wide. Use this to limit where it applies.'), TextInput::make('cooldown_seconds')
Select::make('tenant_allowlist') ->label('Cooldown (seconds)')
->label('Selected tenants') ->numeric()
->multiple() ->minValue(0)
->options(self::tenantOptions()) ->nullable(),
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST) Toggle::make('quiet_hours_enabled')
->native(false) ->label('Enable quiet hours')
->helperText('Only these tenants will trigger this rule.'), ->default(false)
]), ->live(),
Section::make('Delivery') TextInput::make('quiet_hours_start')
->schema([ ->label('Quiet hours start')
TextInput::make('cooldown_seconds') ->type('time')
->label('Cooldown (seconds)') ->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
->numeric() TextInput::make('quiet_hours_end')
->minValue(0) ->label('Quiet hours end')
->nullable(), ->type('time')
Toggle::make('quiet_hours_enabled') ->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
->label('Enable quiet hours') Select::make('quiet_hours_timezone')
->default(false) ->label('Quiet hours timezone')
->live(), ->options(self::timezoneOptions())
TextInput::make('quiet_hours_start') ->searchable()
->label('Quiet hours start') ->native(false)
->type('time') ->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')), Select::make('destination_ids')
TextInput::make('quiet_hours_end') ->label('Destinations')
->label('Quiet hours end') ->multiple()
->type('time') ->required()
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')), ->options(self::destinationOptions())
Select::make('quiet_hours_timezone') ->native(false),
->label('Quiet hours timezone')
->options(self::timezoneOptions())
->searchable()
->native(false)
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('destination_ids')
->label('Destinations')
->multiple()
->required()
->options(self::destinationOptions())
->native(false),
]),
]); ]);
} }
@ -379,8 +366,6 @@ public static function eventTypeOptions(): array
AlertRule::EVENT_HIGH_DRIFT => 'High drift', AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed', AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due', AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
]; ];
} }

View File

@ -5,6 +5,7 @@
namespace App\Filament\Resources\AlertRuleResource\Pages; namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\AlertRuleResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -15,6 +16,10 @@ class ListAlertRules extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
...app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
),
CreateAction::make() CreateAction::make()
->label('Create rule') ->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()), ->disabled(fn (): bool => ! AlertRuleResource::canCreate()),

View File

@ -765,7 +765,7 @@ public static function table(Table $table): Table
Action::make('view_runs') Action::make('view_runs')
->label('View in Operations') ->label('View in Operations')
->url(OperationRunLinks::index($tenant)), ->url(OperationRunLinks::index($tenant)),
]); ])->sendToDatabase($user);
} }
$notification->send(); $notification->send();
@ -862,7 +862,7 @@ public static function table(Table $table): Table
Action::make('view_runs') Action::make('view_runs')
->label('View in Operations') ->label('View in Operations')
->url(OperationRunLinks::index($tenant)), ->url(OperationRunLinks::index($tenant)),
]); ])->sendToDatabase($user);
} }
$notification->send(); $notification->send();

View File

@ -18,6 +18,7 @@
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -104,9 +105,10 @@ public function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Removal already queued')
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->body('A matching remove operation is already queued or running.')
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -194,9 +196,10 @@ public function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Removal already queued')
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->body('A matching remove operation is already queued or running.')
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -140,44 +140,30 @@ public static function form(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Profile') TextInput::make('name')
->schema([ ->required()
TextInput::make('name') ->maxLength(255),
->required() Textarea::make('description')
->maxLength(255) ->rows(3)
->helperText('A descriptive name for this baseline profile.'), ->maxLength(1000),
Textarea::make('description') TextInput::make('version_label')
->rows(3) ->label('Version label')
->maxLength(1000) ->maxLength(50),
->helperText('Explain the purpose and scope of this baseline.'), Select::make('status')
TextInput::make('version_label') ->required()
->label('Version label') ->options([
->maxLength(50) BaselineProfile::STATUS_DRAFT => 'Draft',
->placeholder('e.g. v2.1 — February rollout') BaselineProfile::STATUS_ACTIVE => 'Active',
->helperText('Optional label to identify this version.'), BaselineProfile::STATUS_ARCHIVED => 'Archived',
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
]) ])
->columns(2) ->default(BaselineProfile::STATUS_DRAFT)
->columnSpanFull(), ->native(false),
Section::make('Scope') Select::make('scope_jsonb.policy_types')
->schema([ ->label('Policy type scope')
Select::make('scope_jsonb.policy_types') ->multiple()
->label('Policy type scope') ->options(self::policyTypeOptions())
->multiple() ->helperText('Leave empty to include all policy types.')
->options(self::policyTypeOptions()) ->native(false),
->helperText('Leave empty to include all policy types.')
->native(false),
])
->columnSpanFull(),
]); ]);
} }

View File

@ -17,8 +17,7 @@ protected function getHeaderActions(): array
return [ return [
CreateAction::make() CreateAction::make()
->label('Create baseline profile') ->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()) ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
]; ];
} }
} }

View File

@ -11,9 +11,6 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineCaptureService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
@ -91,36 +88,10 @@ private function captureAction(): Action
return; return;
} }
$run = $result['run'] ?? null; Notification::make()
->title('Capture enqueued')
if (! $run instanceof \App\Models\OperationRun) { ->body('Baseline snapshot capture has been started.')
Notification::make() ->success()
->title('Cannot start capture')
->body('Reason: missing operation run')
->danger()
->send();
return;
}
$viewAction = Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $sourceTenant));
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
->actions([$viewAction])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $run->type)
->actions([$viewAction])
->send(); ->send();
}); });
} }

View File

@ -10,10 +10,9 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords class ListEntraGroups extends ListRecords
@ -32,7 +31,7 @@ protected function getHeaderActions(): array
Action::make('sync_groups') Action::make('sync_groups')
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('warning')
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -58,8 +57,10 @@ protected function getHeaderActions(): array
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View Run') ->label('View Run')
@ -79,13 +80,16 @@ protected function getHeaderActions(): array
operationRun: $opRun operationRun: $opRun
)); ));
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
OperationUxPresenter::queuedToast((string) $opRun->type) ->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View Run') ->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->sendToDatabase($user)
->send(); ->send();
}) })
) )

View File

@ -7,10 +7,8 @@
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder; use App\Services\Drift\DriftFindingDiffBuilder;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
@ -25,8 +23,6 @@
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
@ -40,8 +36,6 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use InvalidArgumentException;
use Throwable;
use UnitEnum; use UnitEnum;
class FindingResource extends Resource class FindingResource extends Resource
@ -70,7 +64,7 @@ public static function canViewAny(): bool
return false; return false;
} }
return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant); return $user->can(Capabilities::TENANT_VIEW, $tenant);
} }
public static function canView(Model $record): bool public static function canView(Model $record): bool
@ -87,7 +81,7 @@ public static function canView(Model $record): bool
return false; return false;
} }
if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) { if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
return false; return false;
} }
@ -101,12 +95,12 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).') ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action supports acknowledging all matching findings.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.'); ->exempt(ActionSurfaceSlot::DetailHeader, 'View page intentionally has no additional header actions.');
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -152,34 +146,12 @@ public static function infolist(Schema $schema): Schema
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'), TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
TextEntry::make('sla_days')->label('SLA days')->placeholder('—'),
TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'),
TextEntry::make('owner_user_id')
->label('Owner')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('assignee_user_id')
->label('Assignee')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'),
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
TextEntry::make('closed_by_user_id')
->label('Closed by')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('created_at')->label('Created')->dateTime(), TextEntry::make('created_at')->label('Created')->dateTime(),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Diff') Section::make('Diff')
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
->schema([ ->schema([
ViewEntry::make('settings_diff') ViewEntry::make('settings_diff')
->label('') ->label('')
@ -305,65 +277,22 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
Tables\Columns\TextColumn::make('due_at')
->label('Due')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('assigneeUser.name')
->label('Assignee')
->placeholder('—'),
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
]) ])
->filters([ ->filters([
Tables\Filters\Filter::make('open')
->label('Open')
->default()
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
Tables\Filters\Filter::make('overdue')
->label('Overdue')
->query(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())
->whereNotNull('due_at')
->where('due_at', '<', now())),
Tables\Filters\Filter::make('high_severity')
->label('High severity')
->query(fn (Builder $query): Builder => $query->whereIn('severity', [
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
])),
Tables\Filters\Filter::make('my_assigned')
->label('My assigned')
->query(function (Builder $query): Builder {
$userId = auth()->id();
if (! is_numeric($userId)) {
return $query->whereRaw('1 = 0');
}
return $query->where('assignee_user_id', (int) $userId);
}),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options([ ->options([
Finding::STATUS_NEW => 'New', Finding::STATUS_NEW => 'New',
Finding::STATUS_TRIAGED => 'Triaged', Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
Finding::STATUS_ACKNOWLEDGED => 'Triaged (legacy acknowledged)',
Finding::STATUS_IN_PROGRESS => 'In progress',
Finding::STATUS_REOPENED => 'Reopened',
Finding::STATUS_RESOLVED => 'Resolved',
Finding::STATUS_CLOSED => 'Closed',
Finding::STATUS_RISK_ACCEPTED => 'Risk accepted',
]) ])
->label('Status'), ->default(Finding::STATUS_NEW),
Tables\Filters\SelectFilter::make('finding_type') Tables\Filters\SelectFilter::make('finding_type')
->options([ ->options([
Finding::FINDING_TYPE_DRIFT => 'Drift', Finding::FINDING_TYPE_DRIFT => 'Drift',
Finding::FINDING_TYPE_PERMISSION_POSTURE => 'Permission posture',
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles',
]) ])
->label('Type'), ->default(Finding::FINDING_TYPE_DRIFT),
Tables\Filters\Filter::make('scope_key') Tables\Filters\Filter::make('scope_key')
->form([ ->form([
TextInput::make('scope_key') TextInput::make('scope_key')
@ -407,7 +336,42 @@ public static function table(Table $table): Table
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
...static::workflowActions(), UiEnforcement::forAction(
Actions\Action::make('acknowledge')
->label('Acknowledge')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
->action(function (Finding $record): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || ! $user instanceof User) {
return;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
Notification::make()
->title('Finding belongs to a different tenant')
->danger()
->send();
return;
}
$record->acknowledge($user);
Notification::make()
->title('Finding acknowledged')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
]) ])
->label('More') ->label('More')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
@ -416,12 +380,12 @@ public static function table(Table $table): Table
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('triage_selected') BulkAction::make('acknowledge_selected')
->label('Triage selected') ->label('Acknowledge selected')
->icon('heroicon-o-check') ->icon('heroicon-o-check')
->color('gray') ->color('gray')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records, FindingWorkflowService $workflow): void { ->action(function (Collection $records): void {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
$user = auth()->user(); $user = auth()->user();
@ -429,9 +393,8 @@ public static function table(Table $table): Table
return; return;
} }
$triagedCount = 0; $acknowledgedCount = 0;
$skippedCount = 0; $skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) { foreach ($records as $record) {
if (! $record instanceof Finding) { if (! $record instanceof Finding) {
@ -446,343 +409,30 @@ public static function table(Table $table): Table
continue; continue;
} }
if (! in_array((string) $record->status, [ if ($record->status !== Finding::STATUS_NEW) {
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
try { $record->acknowledge($user);
$workflow->triage($record, $tenant, $user); $acknowledgedCount++;
$triagedCount++;
} catch (Throwable) {
$failedCount++;
}
} }
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.'; $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) { if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}."; $body .= " Skipped {$skippedCount}.";
} }
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make() Notification::make()
->title('Bulk triage completed') ->title('Bulk acknowledge completed')
->body($body) ->body($body)
->status($failedCount > 0 ? 'warning' : 'success') ->success()
->send(); ->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
) )
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('assign_selected')
->label('Assign selected')
->icon('heroicon-o-user-plus')
->color('gray')
->requiresConfirmation()
->form([
Select::make('assignee_user_id')
->label('Assignee')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Select::make('owner_user_id')
->label('Owner')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null;
$ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null;
$assignedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$assignedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk assign completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('resolve_selected')
->label('Resolve selected')
->icon('heroicon-o-check-badge')
->color('success')
->requiresConfirmation()
->form([
Textarea::make('resolved_reason')
->label('Resolution reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['resolved_reason'] ?? '');
$resolvedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->resolve($record, $tenant, $user, $reason);
$resolvedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk resolve completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('close_selected')
->label('Close selected')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Close reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['closed_reason'] ?? '');
$closedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->close($record, $tenant, $user, $reason);
$closedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Closed {$closedCount} finding".($closedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk close completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('risk_accept_selected')
->label('Risk accept selected')
->icon('heroicon-o-shield-check')
->color('warning')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['closed_reason'] ?? '');
$acceptedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->riskAccept($record, $tenant, $user, $reason);
$acceptedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk risk accept completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
@ -794,7 +444,6 @@ public static function getEloquentQuery(): Builder
$tenantId = Tenant::current()?->getKey(); $tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
->addSelect([ ->addSelect([
'subject_display_name' => InventoryItem::query() 'subject_display_name' => InventoryItem::query()
->select('display_name') ->select('display_name')
@ -812,300 +461,4 @@ public static function getPages(): array
'view' => Pages\ViewFinding::route('/{record}'), 'view' => Pages\ViewFinding::route('/{record}'),
]; ];
} }
/**
* @return array<int, Actions\Action>
*/
public static function workflowActions(): array
{
return [
static::triageAction(),
static::startProgressAction(),
static::assignAction(),
static::resolveAction(),
static::closeAction(),
static::riskAcceptAction(),
static::reopenAction(),
];
}
public static function triageAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('triage')
->label('Triage')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding triaged',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function startProgressAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('start_progress')
->label('Start progress')
->icon('heroicon-o-play')
->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding moved to in progress',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function assignAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('assign')
->label('Assign')
->icon('heroicon-o-user-plus')
->color('gray')
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->fillForm(fn (Finding $record): array => [
'assignee_user_id' => $record->assignee_user_id,
'owner_user_id' => $record->owner_user_id,
])
->form([
Select::make('assignee_user_id')
->label('Assignee')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Select::make('owner_user_id')
->label('Owner')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding assignment updated',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign(
$finding,
$tenant,
$user,
is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null,
is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null,
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function resolveAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('resolve')
->label('Resolve')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation()
->form([
Textarea::make('resolved_reason')
->label('Resolution reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding resolved',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
$finding,
$tenant,
$user,
(string) ($data['resolved_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function closeAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('close')
->label('Close')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Close reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding closed',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
$finding,
$tenant,
$user,
(string) ($data['closed_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function riskAcceptAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('risk_accept')
->label('Risk accept')
->icon('heroicon-o-shield-check')
->color('warning')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding marked as risk accepted',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
$finding,
$tenant,
$user,
(string) ($data['closed_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function reopenAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('reopen')
->label('Reopen')
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->requiresConfirmation()
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding reopened',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
/**
* @param callable(Finding, Tenant, User): Finding $callback
*/
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
Notification::make()
->title('Finding belongs to a different tenant')
->danger()
->send();
return;
}
try {
$callback($record, $tenant, $user);
} catch (InvalidArgumentException $e) {
Notification::make()
->title('Workflow action failed')
->body($e->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title($successTitle)
->success()
->send();
}
/**
* @return array<int, string>
*/
private static function tenantMemberOptions(): array
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return [];
}
return TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
->orderBy('users.name')
->pluck('users.name', 'users.id')
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
->all();
}
} }

View File

@ -3,16 +3,8 @@
namespace App\Filament\Resources\FindingResource\Pages; namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
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;
@ -21,7 +13,6 @@
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Throwable;
class ListFindings extends ListRecords class ListFindings extends ListRecords
{ {
@ -29,195 +20,71 @@ class ListFindings extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$actions = []; return [
UiEnforcement::forAction(
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) { Actions\Action::make('acknowledge_all_matching')
$actions[] = UiEnforcement::forAction( ->label('Acknowledge all matching')
Actions\Action::make('backfill_lifecycle') ->icon('heroicon-o-check')
->label('Backfill findings lifecycle')
->icon('heroicon-o-wrench-screwdriver')
->color('gray') ->color('gray')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Backfill findings lifecycle') ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->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.') ->modalDescription(function (): string {
->action(function (OperationRunService $operationRuns): void { $count = $this->getAllMatchingCount();
$user = auth()->user();
if (! $user instanceof User) { return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
abort(403); })
->form(function (): array {
$count = $this->getAllMatchingCount();
if ($count <= 100) {
return [];
} }
$tenant = \Filament\Facades\Filament::getTenant(); return [
TextInput::make('confirmation')
->label('Type ACKNOWLEDGE to confirm')
->required()
->in(['ACKNOWLEDGE'])
->validationMessages([
'in' => 'Please type ACKNOWLEDGE to confirm.',
]),
];
})
->action(function (array $data): void {
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
if (! $tenant instanceof Tenant) { if ($count === 0) {
abort(404); Notification::make()
} ->title('No matching findings')
->body('There are no new findings matching the current filters.')
$opRun = $operationRuns->ensureRunWithIdentity( ->warning()
tenant: $tenant,
type: 'findings.lifecycle.backfill',
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'trigger' => 'backfill',
],
context: [
'workspace_id' => (int) $tenant->workspace_id,
'initiator_user_id' => (int) $user->getKey(),
],
initiator: $user,
);
$runUrl = OperationRunLinks::view($opRun, $tenant);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send(); ->send();
return; return;
} }
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void { $updated = $query->update([
BackfillFindingLifecycleJob::dispatch( 'status' => Finding::STATUS_ACKNOWLEDGED,
tenantId: (int) $tenant->getKey(), 'acknowledged_at' => now(),
workspaceId: (int) $tenant->workspace_id, 'acknowledged_by_user_id' => auth()->id(),
initiatorUserId: (int) $user->getKey(), ]);
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this); $this->deselectAllTableRecords();
$this->resetPage();
OperationUxPresenter::queuedToast((string) $opRun->type) Notification::make()
->body('The backfill will run in the background. You can continue working while it completes.') ->title('Bulk acknowledge completed')
->actions([ ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
Actions\Action::make('view_run') ->success()
->label('View run')
->url($runUrl),
])
->send(); ->send();
}) })
) )
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(); ->apply(),
} ];
$actions[] = UiEnforcement::forAction(
Actions\Action::make('triage_all_matching')
->label('Triage all matching')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->modalDescription(function (): string {
$count = $this->getAllMatchingCount();
return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
})
->form(function (): array {
$count = $this->getAllMatchingCount();
if ($count <= 100) {
return [];
}
return [
TextInput::make('confirmation')
->label('Type TRIAGE to confirm')
->required()
->in(['TRIAGE'])
->validationMessages([
'in' => 'Please type TRIAGE to confirm.',
]),
];
})
->action(function (FindingWorkflowService $workflow): void {
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
if ($count === 0) {
Notification::make()
->title('No matching findings')
->body('There are no new findings matching the current filters to triage.')
->warning()
->send();
return;
}
$user = auth()->user();
$tenant = \Filament\Facades\Filament::getTenant();
if (! $user instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
abort(404);
}
$triagedCount = 0;
$skippedCount = 0;
$failedCount = 0;
$query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void {
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
$skippedCount++;
continue;
}
if (! in_array((string) $finding->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;
continue;
}
try {
$workflow->triage($finding, $tenant, $user);
$triagedCount++;
} catch (Throwable) {
$failedCount++;
}
}
});
$this->deselectAllTableRecords();
$this->resetPage();
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk triage completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
return $actions;
} }
protected function buildAllMatchingQuery(): Builder protected function buildAllMatchingQuery(): Builder
@ -239,27 +106,6 @@ protected function buildAllMatchingQuery(): Builder
$query->where('finding_type', $findingType); $query->where('finding_type', $findingType);
} }
if ($this->filterIsActive('overdue')) {
$query->whereNotNull('due_at')->where('due_at', '<', now());
}
if ($this->filterIsActive('high_severity')) {
$query->whereIn('severity', [
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
]);
}
if ($this->filterIsActive('my_assigned')) {
$userId = auth()->id();
if (is_numeric($userId)) {
$query->where('assignee_user_id', (int) $userId);
} else {
$query->whereRaw('1 = 0');
}
}
$scopeKeyState = $this->getTableFilterState('scope_key') ?? []; $scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
$scopeKey = Arr::get($scopeKeyState, 'scope_key'); $scopeKey = Arr::get($scopeKeyState, 'scope_key');
if (is_string($scopeKey) && $scopeKey !== '') { if (is_string($scopeKey) && $scopeKey !== '') {
@ -280,23 +126,6 @@ protected function buildAllMatchingQuery(): Builder
return $query; return $query;
} }
private function filterIsActive(string $filterName): bool
{
$state = $this->getTableFilterState($filterName);
if ($state === true) {
return true;
}
if (is_array($state)) {
$isActive = Arr::get($state, 'isActive');
return $isActive === true;
}
return false;
}
protected function getAllMatchingCount(): int protected function getAllMatchingCount(): int
{ {
return (int) $this->buildAllMatchingQuery()->count(); return (int) $this->buildAllMatchingQuery()->count();
@ -312,13 +141,13 @@ protected function getStatusFilterValue(): string
: Finding::STATUS_NEW; : Finding::STATUS_NEW;
} }
protected function getFindingTypeFilterValue(): ?string protected function getFindingTypeFilterValue(): string
{ {
$state = $this->getTableFilterState('finding_type') ?? []; $state = $this->getTableFilterState('finding_type') ?? [];
$value = Arr::get($state, 'value'); $value = Arr::get($state, 'value');
return is_string($value) && $value !== '' return is_string($value) && $value !== ''
? $value ? $value
: null; : Finding::FINDING_TYPE_DRIFT;
} }
} }

View File

@ -3,20 +3,9 @@
namespace App\Filament\Resources\FindingResource\Pages; namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
class ViewFinding extends ViewRecord class ViewFinding extends ViewRecord
{ {
protected static string $resource = FindingResource::class; protected static string $resource = FindingResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make(FindingResource::workflowActions())
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
}
} }

View File

@ -44,7 +44,7 @@ protected function getHeaderActions(): array
Action::make('run_inventory_sync') Action::make('run_inventory_sync')
->label('Run Inventory Sync') ->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('warning')
->form([ ->form([
Select::make('policy_types') Select::make('policy_types')
->label('Policy types') ->label('Policy types')
@ -167,7 +167,10 @@ protected function getHeaderActions(): array
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View Run') ->label('View Run')

View File

@ -471,8 +471,10 @@ public static function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -601,7 +603,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
@ -641,30 +643,19 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
$runUrl = OperationRunLinks::view($opRun, $tenant); Notification::make()
->title('Policy delete queued')
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->body("Queued deletion for {$count} policies.")
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast((string) $opRun->type)
->body("Queued deletion for {$count} policies.") ->body("Queued deletion for {$count} policies.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url($runUrl), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->duration(8000)
->sendToDatabase($user)
->send(); ->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
@ -739,6 +730,18 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)
@ -800,8 +803,10 @@ public static function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -895,6 +900,18 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
if ($count >= 20) {
Notification::make()
->title('Bulk export started')
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
}
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')

View File

@ -13,6 +13,7 @@
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListPolicies extends ListRecords class ListPolicies extends ListRecords
@ -67,8 +68,10 @@ private function makeSyncAction(): Actions\Action
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -303,6 +303,20 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
Notification::make()
->title('Policy version prune queued')
->body("Queued prune for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.prune') OperationUxPresenter::queuedToast('policy_version.prune')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -462,6 +476,20 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
Notification::make()
->title('Policy version force delete queued')
->body("Queued force delete for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.force_delete') OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')

View File

@ -18,8 +18,6 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -34,7 +32,6 @@
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\Filter;
@ -404,39 +401,29 @@ public static function form(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Connection') TextInput::make('display_name')
->schema([ ->label('Display name')
TextInput::make('display_name') ->required()
->label('Display name') ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->required() ->maxLength(255),
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) TextInput::make('entra_tenant_id')
->maxLength(255), ->label('Entra tenant ID')
TextInput::make('entra_tenant_id') ->required()
->label('Entra tenant ID') ->maxLength(255)
->required() ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255) ->rules(['uuid']),
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) Toggle::make('is_default')
->rules(['uuid']), ->label('Default connection')
Toggle::make('is_default') ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->label('Default connection') ->helperText('Exactly one default connection is required per tenant/provider.'),
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) TextInput::make('status')
->helperText('Exactly one default connection is required per tenant/provider.'), ->label('Status')
]) ->disabled()
->columns(2) ->dehydrated(false),
->columnSpanFull(), TextInput::make('health_status')
Section::make('Status') ->label('Health')
->schema([ ->disabled()
TextInput::make('status') ->dehydrated(false),
->label('Status')
->disabled()
->dehydrated(false),
TextInput::make('health_status')
->label('Health')
->disabled()
->dehydrated(false),
])
->columns(2)
->columnSpanFull(),
]); ]);
} }
@ -588,7 +575,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -625,9 +612,10 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A connection check is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -663,9 +651,10 @@ public static function table(Table $table): Table
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Connection check queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Health check was queued and will run in the background.')
->success()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -684,7 +673,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -725,9 +714,10 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('An inventory sync is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -757,9 +747,10 @@ public static function table(Table $table): Table
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Inventory sync queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Inventory sync was queued and will run in the background.')
->success()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -778,7 +769,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -819,9 +810,10 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A compliance snapshot is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -851,9 +843,10 @@ public static function table(Table $table): Table
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Compliance snapshot queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -16,8 +16,6 @@
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -256,9 +254,10 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A connection check is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -294,9 +293,10 @@ protected function getHeaderActions(): array
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Connection check queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Health check was queued and will run in the background.')
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -493,9 +493,10 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('An inventory sync is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -525,9 +526,10 @@ protected function getHeaderActions(): array
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Inventory sync queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Inventory sync was queued and will run in the background.')
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -604,9 +606,10 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Run already queued')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A compliance snapshot is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -636,9 +639,10 @@ protected function getHeaderActions(): array
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Compliance snapshot queued')
OperationUxPresenter::queuedToast((string) $result->run->type) ->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -2,8 +2,6 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource\Pages;
use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob;
@ -53,7 +51,6 @@
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -774,7 +771,216 @@ public static function table(Table $table): Table
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
ActionGroup::make([ ActionGroup::make([
static::rerunActionWithGate(), UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $record->requested_items ?? null,
dryRun: (bool) $record->is_dry_run,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $record->group_mapping ?? []
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction( UiEnforcement::forTableAction(
Actions\Action::make('restore') Actions\Action::make('restore')
->label('Restore') ->label('Restore')
@ -1351,37 +1557,6 @@ public static function createRestoreRun(array $data): RestoreRun
abort(403); abort(403);
} }
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
/** @var BackupSet $backupSet */ /** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']); $backupSet = BackupSet::findOrFail($data['backup_set_id']);
@ -1535,23 +1710,11 @@ public static function createRestoreRun(array $data): RestoreRun
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0); Notification::make()
$existingOpRun = $existingOpRunId > 0 ->title('Restore already queued')
? \App\Models\OperationRun::query()->find($existingOpRunId) ->body('Reusing the active restore run.')
: null; ->info()
->send();
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return $existing; return $existing;
} }
@ -1573,23 +1736,11 @@ public static function createRestoreRun(array $data): RestoreRun
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0); Notification::make()
$existingOpRun = $existingOpRunId > 0 ->title('Restore already queued')
? \App\Models\OperationRun::query()->find($existingOpRunId) ->body('Reusing the active restore run.')
: null; ->info()
->send();
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return $existing; return $existing;
} }
@ -1825,343 +1976,4 @@ private static function normalizeGroupMapping(mixed $mapping): array
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== ''); return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
} }
/**
* Build the rerun table action with UiEnforcement + write gate disabled state.
*
* UiEnforcement::apply() overrides ->disabled() and ->tooltip(), so the gate
* check must compose on top of the enforcement action AFTER apply(). This method
* extracts the rerun action into its own builder to keep the table definition clean.
*/
private static function rerunActionWithGate(): Actions\Action|BulkAction
{
/** @var Actions\Action $action */
$action = UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
resourceType: 'restore_run',
resourceId: (string) $record->getKey(),
context: [
'metadata' => [
'operation_type' => 'restore.rerun',
'reason_code' => $e->reasonCode,
'backup_set_id' => $backupSet?->getKey(),
'original_restore_run_id' => $record->getKey(),
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $record->requested_items ?? null,
dryRun: (bool) $record->is_dry_run,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $record->group_mapping ?? []
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply();
// Compose write gate disabled/tooltip on top of UiEnforcement's RBAC check.
// UiEnforcement::apply() sets its own ->disabled() / ->tooltip();
// we override here to merge both concerns.
$action->disabled(function (?Model $record = null): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
// Check RBAC capability first (mirrors UiEnforcement logic)
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
// Then check write gate
return app(WriteGateInterface::class)->wouldBlock($tenant);
});
$action->tooltip(function (?Model $record = null): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
if (! $tenant instanceof Tenant) {
return 'Tenant unavailable';
}
// Check RBAC capability first
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
// Then check write gate
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
return $e->reasonMessage;
}
return null;
});
return $action;
}
} }

View File

@ -1,352 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
use UnitEnum;
class ReviewPackResource extends Resource
{
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Review Packs';
protected static ?int $navigationSort = 50;
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return false;
}
if ($record instanceof ReviewPack) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Status')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
TextEntry::make('file_size')
->label('File size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Summary')
->schema([
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
TextEntry::make('summary.data_freshness.permission_posture')
->label('Permission posture freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.entra_admin_roles')
->label('Entra admin roles freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.findings')
->label('Findings freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.hardening')
->label('Hardening freshness')
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Options')
->schema([
TextEntry::make('options.include_pii')
->label('Include PII')
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
TextEntry::make('options.include_operations')
->label('Include operations')
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
])
->columns(2)
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
TextEntry::make('operationRun.id')
->label('Operation run')
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
: null)
->openUrlInNewTab()
->placeholder('—'),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
TextEntry::make('created_at')->label('Created')->dateTime(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
->sortable(),
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
Tables\Columns\TextColumn::make('generated_at')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('file_size')
->label('Size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->since()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(collect(ReviewPackStatus::cases())
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
->all()),
])
->actions([
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
->url(function (ReviewPack $record): string {
return app(ReviewPackService::class)->generateDownloadUrl($record);
})
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('expire')
->label('Expire')
->icon('heroicon-o-clock')
->color('danger')
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
->requiresConfirmation()
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
->action(function (ReviewPack $record): void {
if ($record->file_path && $record->file_disk) {
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
}
$record->update(['status' => ReviewPackStatus::Expired->value]);
Notification::make()
->success()
->title('Review pack expired')
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
])
->emptyStateHeading('No review packs yet')
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
}
public static function getEloquentQuery(): Builder
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
{
return [
'index' => Pages\ListReviewPacks::route('/'),
'view' => Pages\ViewReviewPack::route('/{record}'),
];
}
/**
* @param array<string, mixed> $data
*/
public static function executeGeneration(array $data): void
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
return;
}
$service = app(ReviewPackService::class);
if ($service->checkActiveRun($tenant)) {
Notification::make()->warning()->title('A review pack is already being generated.')->send();
return;
}
$options = [
'include_pii' => (bool) ($data['include_pii'] ?? true),
'include_operations' => (bool) ($data['include_operations'] ?? true),
];
$reviewPack = $service->generate($tenant, $user, $options);
if (! $reviewPack->wasRecentlyCreated) {
Notification::make()
->success()
->title('Review pack already available')
->body('A matching review pack is already ready. No new run was started.')
->actions([
Actions\Action::make('view_pack')
->label('View pack')
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
])
->send();
return;
}
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListReviewPacks extends ListRecords
{
protected static string $resource = ReviewPackResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('generate_pack')
->label('Generate Pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
ReviewPackResource::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
];
}
}

View File

@ -1,73 +0,0 @@
<?php
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
class ViewReviewPack extends ViewRecord
{
protected static string $resource = ReviewPackResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
/** @var ReviewPack $record */
$record = $this->record;
$options = array_merge($record->options ?? [], [
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
]);
ReviewPackResource::executeGeneration($options);
})
->form(function (): array {
/** @var ReviewPack $record */
$record = $this->record;
$currentOptions = $record->options ?? [];
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default((bool) ($currentOptions['include_pii'] ?? true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default((bool) ($currentOptions['include_operations'] ?? true)),
]),
];
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
];
}
}

View File

@ -12,6 +12,7 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
@ -45,7 +46,6 @@
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@ -75,13 +75,29 @@ class TenantResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Settings'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
/**
* Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed.
*/
public static function canCreate(): bool public static function canCreate(): bool
{ {
return false; $user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (static::userCanManageAnyTenant($user)) {
return true;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
} }
public static function canEdit(Model $record): bool public static function canEdit(Model $record): bool
@ -354,8 +370,10 @@ public static function table(Table $table): Table
} }
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View Run') ->label('View Run')
@ -470,7 +488,6 @@ public static function table(Table $table): Table
->action(function ( ->action(function (
Tenant $record, Tenant $record,
StartVerification $verification, StartVerification $verification,
\Filament\Tables\Contracts\HasTable $livewire,
): void { ): void {
$user = auth()->user(); $user = auth()->user();
@ -495,8 +512,6 @@ public static function table(Table $table): Table
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Another operation is already running')
->body('Please wait for the active run to finish.') ->body('Please wait for the active run to finish.')
@ -512,9 +527,10 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Verification already running')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A verification run is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -568,9 +584,9 @@ public static function table(Table $table): Table
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); Notification::make()
->title('Verification started')
OperationUxPresenter::queuedToast((string) $result->run->type) ->success()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -753,6 +769,7 @@ public static function table(Table $table): Table
->body('No eligible tenants selected.') ->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle') ->icon('heroicon-o-information-circle')
->info() ->info()
->sendToDatabase($user)
->send(); ->send();
return; return;
@ -815,153 +832,69 @@ public static function infolist(Schema $schema): Schema
// ... [Infolist Omitted - No Change] ... // ... [Infolist Omitted - No Change] ...
return $schema return $schema
->schema([ ->schema([
Section::make('Identity') Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Infolists\Components\ViewEntry::make('provider_connection_state')
->label('Provider connection')
->state(fn (Tenant $record): array => static::providerConnectionState($record))
->view('filament.infolists.entries.provider-connection-state')
->columnSpanFull(),
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)),
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
Infolists\Components\TextEntry::make('rbac_role_definition_id')->label('Role definition ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_scope_mode')->label('RBAC scope'),
Infolists\Components\TextEntry::make('rbac_scope_id')->label('Scope ID'),
Infolists\Components\TextEntry::make('rbac_group_id')->label('RBAC group ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')->label('Role assignment ID')->copyable(),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state))
->copyable(),
Infolists\Components\RepeatableEntry::make('permissions')
->label('Required permissions')
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
->schema([ ->schema([
Infolists\Components\TextEntry::make('name'), Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(), Infolists\Components\TextEntry::make('type')->badge(),
Infolists\Components\TextEntry::make('domain')->copyable(), Infolists\Components\TextEntry::make('features')
->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
])
->columns(2)
->columnSpanFull(),
Section::make('Provider')
->schema([
Infolists\Components\ViewEntry::make('provider_connection_state')
->label('Provider connection')
->state(fn (Tenant $record): array => static::providerConnectionState($record))
->view('filament.infolists.entries.provider-connection-state')
->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('RBAC')
->schema([
Infolists\Components\TextEntry::make('rbac_not_configured_hint')
->label('Status')
->state('Not configured — Intune RBAC has not been set up for this tenant. Write operations will be blocked.')
->icon('heroicon-o-shield-exclamation')
->color('warning')
->columnSpanFull()
->visible(fn (Tenant $record): bool => blank($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus))
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_explanation')
->label('Summary')
->state(function (Tenant $record): string {
$status = $record->rbac_status;
$lastChecked = $record->rbac_last_checked_at;
$threshold = (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24);
if (blank($status) || $status === 'not_configured') {
return 'RBAC is not configured. Write operations to this tenant are blocked until RBAC is set up.';
}
if (in_array($status, ['degraded', 'failed', 'error', 'missing', 'partial'], true)) {
return 'RBAC health check reported an unhealthy state. Write operations are blocked.';
}
if ($status === 'ok' && ($lastChecked === null || $lastChecked->diffInHours(now()) >= $threshold)) {
return "RBAC status is OK but the last health check is older than {$threshold} hours. Write operations are blocked until refreshed.";
}
return 'RBAC is healthy and up to date. Write operations are permitted.';
})
->columnSpanFull()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_last_checked_at')
->label('Last checked')
->since()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_display_name')
->label('RBAC role')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Section::make('RBAC Details')
->schema([
Infolists\Components\TextEntry::make('rbac_status_reason')
->label('Reason'),
Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID')
->copyable(),
Infolists\Components\TextEntry::make('rbac_scope_mode')
->label('Scope'),
Infolists\Components\TextEntry::make('rbac_scope_id')
->label('Scope ID'),
Infolists\Components\TextEntry::make('rbac_group_id')
->label('Group ID')
->copyable(),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')
->label('Role assignment ID')
->copyable(),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
])
->columns(2)
->collapsible()
->collapsed()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
])
->columns(2)
->columnSpanFull()
->collapsible(),
Section::make('Integration')
->schema([
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state))
->copyable()
->columnSpanFull(),
])
->columnSpanFull()
->collapsible(),
Section::make('Metadata')
->schema([
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
])
->columns(2)
->columnSpanFull()
->collapsed(),
Section::make('Required permissions')
->schema([
Infolists\Components\RepeatableEntry::make('permissions')
->label('')
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
->schema([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),
Infolists\Components\TextEntry::make('features')
->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
])
->columnSpanFull(),
])
->columnSpanFull()
->collapsible(),
]); ]);
} }
@ -1005,6 +938,7 @@ public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListTenants::route('/'), 'index' => Pages\ListTenants::route('/'),
'create' => Pages\CreateTenant::route('/create'),
'view' => Pages\ViewTenant::route('/{record}'), 'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'), 'edit' => Pages\EditTenant::route('/{record}/edit'),
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'), 'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),
@ -1605,7 +1539,10 @@ public static function syncRoleDefinitionsAction(): Actions\Action
$runUrl = OperationRunLinks::tenantlessView($opRun); $runUrl = OperationRunLinks::tenantlessView($opRun);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) Notification::make()
->title('Role definitions sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -0,0 +1,41 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
return $data;
}
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => 'owner'],
]);
}
}

View File

@ -13,10 +13,9 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\Action::make('add_tenant') Actions\CreateAction::make()
->label('Add tenant') ->disabled(fn (): bool => ! TenantResource::canCreate())
->icon('heroicon-m-plus') ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.')
->url(route('admin.onboarding'))
->visible(fn (): bool => $this->getTableRecords()->count() > 0), ->visible(fn (): bool => $this->getTableRecords()->count() > 0),
]; ];
} }
@ -24,10 +23,9 @@ protected function getHeaderActions(): array
protected function getTableEmptyStateActions(): array protected function getTableEmptyStateActions(): array
{ {
return [ return [
Actions\Action::make('add_tenant') Actions\CreateAction::make()
->label('Add tenant') ->disabled(fn (): bool => ! TenantResource::canCreate())
->icon('heroicon-m-plus') ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
->url(route('admin.onboarding')),
]; ];
} }
} }

View File

@ -4,21 +4,15 @@
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
use App\Filament\Widgets\Tenant\RecentOperationsSummary; use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner; use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Filament\Widgets\Tenant\TenantVerificationReport; use App\Filament\Widgets\Tenant\TenantVerificationReport;
use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -28,18 +22,12 @@ class ViewTenant extends ViewRecord
{ {
protected static string $resource = TenantResource::class; protected static string $resource = TenantResource::class;
public function getHeaderWidgetsColumns(): int|array
{
return 1;
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [
TenantArchivedBanner::class, TenantArchivedBanner::class,
RecentOperationsSummary::class, RecentOperationsSummary::class,
TenantVerificationReport::class, TenantVerificationReport::class,
AdminRolesSummaryWidget::class,
]; ];
} }
@ -109,8 +97,6 @@ protected function getHeaderActions(): array
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Another operation is already running')
->body('Please wait for the active run to finish.') ->body('Please wait for the active run to finish.')
@ -126,9 +112,10 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Verification already running')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A verification run is already queued or running.')
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -182,9 +169,9 @@ protected function getHeaderActions(): array
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Verification started')
OperationUxPresenter::queuedToast((string) $result->run->type) ->success()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -197,73 +184,6 @@ protected function getHeaderActions(): array
->requireCapability(Capabilities::PROVIDER_RUN) ->requireCapability(Capabilities::PROVIDER_RUN)
->apply(), ->apply(),
TenantResource::rbacAction(), TenantResource::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('refresh_rbac')
->label('Refresh RBAC status')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (Tenant $record): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->ensureRun(
tenant: $record,
type: OperationRunType::RbacHealthCheck->value,
inputs: [
'tenant_id' => (int) $record->getKey(),
'surface' => 'tenant_view_header',
],
initiator: $user,
);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
RefreshTenantRbacHealthJob::dispatch(
(int) $record->getKey(),
(int) $user->getKey(),
$opRun,
);
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('archive') Actions\Action::make('archive')
->label('Deactivate') ->label('Deactivate')

View File

@ -9,42 +9,18 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use Filament\Auth\Http\Responses\Contracts\LoginResponse; use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin; use Filament\Auth\Pages\Login as BaseLogin;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class Login extends BaseLogin class Login extends BaseLogin
{ {
/**
* Filament's base login page uses Livewire-level rate limiting. We override it
* to enforce the System panel policy via Laravel's RateLimiter (SR-003).
*/
protected function rateLimit($maxAttempts, $decaySeconds = 60, $method = null, $component = null): void
{
}
public function authenticate(): ?LoginResponse public function authenticate(): ?LoginResponse
{ {
$data = $this->form->getState(); $data = $this->form->getState();
$email = (string) ($data['email'] ?? ''); $email = (string) ($data['email'] ?? '');
$throttleKey = $this->throttleKey($email);
if (RateLimiter::tooManyAttempts($throttleKey, 10)) {
$this->audit(status: 'failure', email: $email, actor: null, reason: 'throttled');
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'data.email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => (int) ceil($seconds / 60),
]),
]);
}
try { try {
$response = parent::authenticate(); $response = parent::authenticate();
} catch (ValidationException $exception) { } catch (ValidationException $exception) {
RateLimiter::hit($throttleKey, 60);
$this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials'); $this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials');
throw $exception; throw $exception;
@ -64,7 +40,6 @@ public function authenticate(): ?LoginResponse
if (! $user->is_active) { if (! $user->is_active) {
auth('platform')->logout(); auth('platform')->logout();
RateLimiter::hit($throttleKey, 60);
$this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive'); $this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive');
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@ -72,7 +47,6 @@ public function authenticate(): ?LoginResponse
]); ]);
} }
RateLimiter::clear($throttleKey);
$user->forceFill(['last_login_at' => now()])->saveQuietly(); $user->forceFill(['last_login_at' => now()])->saveQuietly();
$this->audit(status: 'success', email: $email, actor: $user); $this->audit(status: 'success', email: $email, actor: $user);
@ -80,14 +54,6 @@ public function authenticate(): ?LoginResponse
return $response; return $response;
} }
private function throttleKey(string $email): string
{
$ip = (string) request()->ip();
$normalizedEmail = mb_strtolower(trim($email));
return "system-login:{$ip}:{$normalizedEmail}";
}
private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void
{ {
$tenant = Tenant::query()->where('external_id', 'platform')->first(); $tenant = Tenant::query()->where('external_id', 'platform')->first();

View File

@ -4,79 +4,16 @@
namespace App\Filament\System\Pages; namespace App\Filament\System\Pages;
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Services\Auth\BreakGlassSession; use App\Services\Auth\BreakGlassSession;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Dashboard as BaseDashboard; use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Database\Eloquent\Model;
class Dashboard extends BaseDashboard class Dashboard extends BaseDashboard
{ {
public string $window = SystemConsoleWindow::LastDay;
/**
* @param array<mixed> $parameters
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'system', $tenant, $shouldGuessMissingParameters);
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
if (! $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)) {
return false;
}
return $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(): void
{
$this->window = SystemConsoleWindow::fromNullable((string) request()->query('window', $this->window))->value;
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
public function getWidgets(): array
{
return [
ControlTowerHealthIndicator::class,
ControlTowerKpis::class,
ControlTowerTopOffenders::class,
ControlTowerRecentFailures::class,
];
}
public function getColumns(): int|array
{
return 1;
}
public function selectedWindow(): SystemConsoleWindow
{
return SystemConsoleWindow::fromNullable($this->window);
}
/** /**
* @return array<Action> * @return array<Action>
*/ */
@ -90,27 +27,6 @@ protected function getHeaderActions(): array
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS); && $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
return [ return [
Action::make('set_window')
->label('Time window')
->icon('heroicon-o-clock')
->color('gray')
->form([
Select::make('window')
->label('Window')
->options(SystemConsoleWindow::options())
->default($this->window)
->required(),
])
->action(function (array $data): void {
$window = SystemConsoleWindow::fromNullable((string) ($data['window'] ?? null));
$this->window = $window->value;
$this->redirect(static::getUrl([
'window' => $window->value,
]));
}),
Action::make('enter_break_glass') Action::make('enter_break_glass')
->label('Enter break-glass mode') ->label('Enter break-glass mode')
->color('danger') ->color('danger')

View File

@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\System\SystemDirectoryLinks;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Tenants extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Tenants';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $slug = 'directory/tenants';
protected string $view = 'filament.system.pages.directory.tenants';
public static function canAccess(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('name')
->query(function (): Builder {
return Tenant::query()
->with('workspace')
->withCount([
'providerConnections',
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'),
'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'),
]);
})
->columns([
TextColumn::make('name')
->label('Tenant')
->searchable(),
TextColumn::make('workspace.name')
->label('Workspace')
->searchable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
TextColumn::make('health')
->label('Health')
->state(fn (Tenant $record): string => $this->healthForTenant($record))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
])
->recordUrl(fn (Tenant $record): string => SystemDirectoryLinks::tenantDetail($record))
->emptyStateHeading('No tenants found')
->emptyStateDescription('Tenants will appear here as inventory is onboarded.');
}
private function healthForTenant(Tenant $tenant): string
{
if ((string) $tenant->status === Tenant::STATUS_ARCHIVED) {
return 'unknown';
}
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) {
return 'critical';
}
if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) {
return 'warn';
}
if ((string) $tenant->status === Tenant::STATUS_ONBOARDING) {
return 'warn';
}
return 'ok';
}
}

View File

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class ViewTenant extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/tenants/{tenant}';
protected string $view = 'filament.system.pages.directory.view-tenant';
public Tenant $tenant;
public static function canAccess(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
}
public function mount(Tenant $tenant): void
{
$tenant->load('workspace');
$this->tenant = $tenant;
}
/**
* @return Collection<int, ProviderConnection>
*/
public function providerConnections(): Collection
{
return ProviderConnection::query()
->where('tenant_id', (int) $this->tenant->getKey())
->orderByDesc('is_default')
->orderBy('provider')
->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']);
}
/**
* @return Collection<int, TenantPermission>
*/
public function tenantPermissions(): Collection
{
return TenantPermission::query()
->where('tenant_id', (int) $this->tenant->getKey())
->orderBy('permission_key')
->limit(20)
->get(['id', 'permission_key', 'status', 'last_checked_at']);
}
/**
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
*/
public function recentRuns(): Collection
{
return OperationRun::query()
->where('tenant_id', (int) $this->tenant->getKey())
->latest('id')
->limit(8)
->get(['id', 'type', 'created_at'])
->map(fn (OperationRun $run): array => [
'id' => (int) $run->getKey(),
'label' => OperationCatalog::label((string) $run->type),
'started' => $run->created_at?->diffForHumans() ?? '—',
'url' => SystemOperationRunLinks::view($run),
]);
}
public function adminTenantUrl(): string
{
return SystemDirectoryLinks::adminTenant($this->tenant);
}
public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
}

View File

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class ViewWorkspace extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/workspaces/{workspace}';
protected string $view = 'filament.system.pages.directory.view-workspace';
public Workspace $workspace;
public static function canAccess(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
}
public function mount(Workspace $workspace): void
{
$workspace->loadCount('tenants');
$this->workspace = $workspace;
}
/**
* @return Collection<int, Tenant>
*/
public function workspaceTenants(): Collection
{
return Tenant::query()
->where('workspace_id', (int) $this->workspace->getKey())
->orderBy('name')
->limit(10)
->get(['id', 'name', 'status', 'workspace_id']);
}
/**
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
*/
public function recentRuns(): Collection
{
return OperationRun::query()
->where('workspace_id', (int) $this->workspace->getKey())
->latest('id')
->limit(8)
->get(['id', 'type', 'created_at'])
->map(fn (OperationRun $run): array => [
'id' => (int) $run->getKey(),
'label' => OperationCatalog::label((string) $run->type),
'started' => $run->created_at?->diffForHumans() ?? '—',
'url' => SystemOperationRunLinks::view($run),
]);
}
public function adminWorkspaceUrl(): string
{
return SystemDirectoryLinks::adminWorkspace($this->workspace);
}
public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
}

View File

@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemDirectoryLinks;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Workspaces extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Workspaces';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $slug = 'directory/workspaces';
protected string $view = 'filament.system.pages.directory.workspaces';
public static function canAccess(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('name')
->query(function (): Builder {
return Workspace::query()
->withCount([
'tenants',
'tenants as onboarding_tenants_count' => fn (Builder $query): Builder => $query->where('status', Tenant::STATUS_ONBOARDING),
]);
})
->columns([
TextColumn::make('name')
->label('Workspace')
->searchable(),
TextColumn::make('tenants_count')
->label('Tenants'),
TextColumn::make('health')
->label('Health')
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
TextColumn::make('failed_runs_24h')
->label('Failed (24h)')
->state(fn (Workspace $record): int => (int) OperationRun::query()
->where('workspace_id', (int) $record->getKey())
->where('created_at', '>=', now()->subDay())
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->count()),
])
->recordUrl(fn (Workspace $record): string => SystemDirectoryLinks::workspaceDetail($record))
->emptyStateHeading('No workspaces found')
->emptyStateDescription('Workspace inventory will appear here once workspaces are created.');
}
private function healthForWorkspace(Workspace $workspace): string
{
$tenantsCount = (int) ($workspace->getAttribute('tenants_count') ?? 0);
$onboardingTenantsCount = (int) ($workspace->getAttribute('onboarding_tenants_count') ?? 0);
if ($tenantsCount === 0) {
return 'unknown';
}
$hasRecentFailures = OperationRun::query()
->where('workspace_id', (int) $workspace->getKey())
->where('created_at', '>=', now()->subDay())
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->exists();
if ($hasRecentFailures) {
return 'critical';
}
if ($onboardingTenantsCount > 0) {
return 'warn';
}
return 'ok';
}
}

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\SystemConsole\OperationRunTriageService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Failures extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Failures';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/failures';
protected string $view = 'filament.system.pages.ops.failures';
public static function getNavigationBadge(): ?string
{
$count = OperationRun::query()
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): string|array|null
{
return 'danger';
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->query(function (): Builder {
return OperationRun::query()
->with(['tenant', 'workspace'])
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value);
})
->columns([
TextColumn::make('id')
->label('Run')
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable(),
TextColumn::make('workspace.name')
->label('Workspace')
->toggleable(),
TextColumn::make('tenant.name')
->label('Tenant')
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
->toggleable(),
TextColumn::make('created_at')->label('Started')->since(),
])
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
->actions([
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$retryRun = $triageService->retry($record, $user);
OperationUxPresenter::queuedToast((string) $retryRun->type)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(SystemOperationRunLinks::view($retryRun)),
])
->send();
}),
Action::make('cancel')
->label('Cancel')
->color('danger')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->cancel($record, $user);
Notification::make()
->title('Run cancelled')
->success()
->send();
}),
Action::make('mark_investigated')
->label('Mark investigated')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations())
->form([
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
Notification::make()
->title('Run marked as investigated')
->success()
->send();
}),
])
->emptyStateHeading('No failed runs found')
->emptyStateDescription('Failed operations will appear here for triage.')
->bulkActions([]);
}
private function canManageOperations(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
}
private function requireManageUser(): PlatformUser
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
abort(403);
}
return $user;
}
}

View File

@ -1,272 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
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\OpsUx\OperationUxPresenter;
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 Illuminate\Validation\ValidationException;
class Runbooks extends Page
{
protected static ?string $navigationLabel = 'Runbooks';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/runbooks';
protected string $view = 'filament.system.pages.ops.runbooks';
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 $preflight = null;
public function scopeLabel(): string
{
if ($this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
return 'All tenants';
}
$tenantName = $this->selectedTenantName();
if ($tenantName !== null) {
return "Single tenant ({$tenantName})";
}
return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant';
}
public function lastRun(): ?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', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY)
->latest('id')
->first();
}
public function selectedTenantName(): ?string
{
if ($this->tenantId === null) {
return null;
}
return Tenant::query()->whereKey($this->tenantId)->value('name');
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPS_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->scopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
$this->scopeMode = $scope->mode;
$this->tenantId = $scope->tenantId;
$this->preflight = $runbookService->preflight($scope);
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->runForm())
->disabled(fn (): bool => ! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0)
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
if (! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0) {
throw ValidationException::withMessages([
'preflight' => 'Run preflight first.',
]);
}
$scope = $this->scopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
? FindingsLifecycleBackfillScope::singleTenant((int) $this->tenantId)
: FindingsLifecycleBackfillScope::allTenants();
$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,
]);
$run = $runbookService->start(
scope: $scope,
initiator: $user,
reason: $reason,
source: 'system_ui',
);
$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 scopeForm(): array
{
return [
Radio::make('scope_mode')
->label('Scope')
->options([
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
])
->default($this->scopeMode)
->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 runForm(): array
{
return [
TextInput::make('typed_confirmation')
->label('Type BACKFILL to confirm')
->visible(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->required(fn (): bool => $this->scopeMode === 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->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
Textarea::make('reason_text')
->label('Reason')
->rows(4)
->maxLength(500)
->required(function (BreakGlassSession $breakGlass): bool {
return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
];
}
}

View File

@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\SystemConsole\OperationRunTriageService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Runs extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Runs';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/runs';
protected string $view = 'filament.system.pages.ops.runs';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->query(function (): Builder {
return OperationRun::query()
->with(['tenant', 'workspace']);
})
->columns([
TextColumn::make('id')
->label('Run')
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable(),
TextColumn::make('workspace.name')
->label('Workspace')
->toggleable(),
TextColumn::make('tenant.name')
->label('Tenant')
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
->toggleable(),
TextColumn::make('initiator_name')->label('Initiator'),
TextColumn::make('created_at')->label('Started')->since(),
])
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
->actions([
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$retryRun = $triageService->retry($record, $user);
OperationUxPresenter::queuedToast((string) $retryRun->type)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(SystemOperationRunLinks::view($retryRun)),
])
->send();
}),
Action::make('cancel')
->label('Cancel')
->color('danger')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->cancel($record, $user);
Notification::make()
->title('Run cancelled')
->success()
->send();
}),
Action::make('mark_investigated')
->label('Mark investigated')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations())
->form([
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
Notification::make()
->title('Run marked as investigated')
->success()
->send();
}),
])
->emptyStateHeading('No operation runs yet')
->emptyStateDescription('Runs from all workspaces will appear here when operations are queued.')
->bulkActions([]);
}
private function canManageOperations(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
}
private function requireManageUser(): PlatformUser
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
abort(403);
}
return $user;
}
}

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\SystemConsole\OperationRunTriageService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\StuckRunClassifier;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Stuck extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Stuck';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/stuck';
protected string $view = 'filament.system.pages.ops.stuck';
public static function getNavigationBadge(): ?string
{
$count = app(StuckRunClassifier::class)
->apply(OperationRun::query())
->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): string|array|null
{
return 'warning';
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->query(function (): Builder {
return app(StuckRunClassifier::class)->apply(
OperationRun::query()
->with(['tenant', 'workspace'])
);
})
->columns([
TextColumn::make('id')
->label('Run')
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('stuck_class')
->label('Stuck class')
->state(function (OperationRun $record): string {
$classification = app(StuckRunClassifier::class)->classify($record);
return $classification === OperationRunStatus::Queued->value ? 'Queued too long' : 'Running too long';
}),
TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable(),
TextColumn::make('workspace.name')
->label('Workspace')
->toggleable(),
TextColumn::make('tenant.name')
->label('Tenant')
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
->toggleable(),
TextColumn::make('created_at')->label('Started')->since(),
])
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
->actions([
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$retryRun = $triageService->retry($record, $user);
OperationUxPresenter::queuedToast((string) $retryRun->type)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(SystemOperationRunLinks::view($retryRun)),
])
->send();
}),
Action::make('cancel')
->label('Cancel')
->color('danger')
->requiresConfirmation()
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->cancel($record, $user);
Notification::make()
->title('Run cancelled')
->success()
->send();
}),
Action::make('mark_investigated')
->label('Mark investigated')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations())
->form([
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
Notification::make()
->title('Run marked as investigated')
->success()
->send();
}),
])
->emptyStateHeading('No stuck runs found')
->emptyStateDescription('Queued and running runs outside thresholds will appear here.')
->bulkActions([]);
}
private function canManageOperations(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
}
private function requireManageUser(): PlatformUser
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
abort(403);
}
return $user;
}
}

View File

@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\SystemConsole\OperationRunTriageService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class ViewRun extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'ops/runs/{run}';
protected string $view = 'filament.system.pages.ops.view-run';
public OperationRun $run;
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(OperationRun $run): void
{
$run->load(['tenant', 'workspace']);
$this->run = $run;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('go_to_runbooks')
->label('Go to runbooks')
->url(Runbooks::getUrl(panel: 'system')),
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run))
->action(function (OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$retryRun = $triageService->retry($this->run, $user);
OperationUxPresenter::queuedToast((string) $retryRun->type)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(SystemOperationRunLinks::view($retryRun)),
])
->send();
}),
Action::make('cancel')
->label('Cancel')
->color('danger')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run))
->action(function (OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->cancel($this->run, $user);
Notification::make()
->title('Run cancelled')
->success()
->send();
}),
Action::make('mark_investigated')
->label('Mark investigated')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations())
->form([
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? ''));
Notification::make()
->title('Run marked as investigated')
->success()
->send();
}),
];
}
private function canManageOperations(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
}
private function requireManageUser(): PlatformUser
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
abort(403);
}
return $user;
}
}

View File

@ -4,8 +4,6 @@
namespace App\Filament\System\Pages; namespace App\Filament\System\Pages;
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
use App\Models\AuditLog;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -20,18 +18,9 @@
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Database\Eloquent\Builder;
class RepairWorkspaceOwners extends Page implements HasTable class RepairWorkspaceOwners extends Page
{ {
use InteractsWithTable;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver'; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Repair workspace owners'; protected static ?string $navigationLabel = 'Repair workspace owners';
@ -51,102 +40,6 @@ public static function canAccess(): bool
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS); return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
} }
public static function getNavigationBadge(): ?string
{
$total = Workspace::query()->count();
$withOwners = WorkspaceMembership::query()
->where('role', WorkspaceRole::Owner->value)
->distinct('workspace_id')
->count('workspace_id');
$ownerless = $total - $withOwners;
return $ownerless > 0 ? (string) $ownerless : null;
}
public static function getNavigationBadgeColor(): string|array|null
{
return 'danger';
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
/**
* @return array<class-string<Widget>|WidgetConfiguration>
*/
protected function getHeaderWidgets(): array
{
return [
RepairWorkspaceOwnersStats::class,
];
}
public function table(Table $table): Table
{
return $table
->heading('Workspaces')
->description('Current workspace ownership status.')
->defaultSort('name', 'asc')
->query(function (): Builder {
return Workspace::query()
->withCount([
'memberships as owner_count' => function (Builder $query): void {
$query->where('role', WorkspaceRole::Owner->value);
},
'memberships as member_count',
'tenants as tenant_count',
]);
})
->columns([
TextColumn::make('name')
->label('Workspace')
->searchable()
->sortable(),
TextColumn::make('owner_count')
->label('Owners')
->badge()
->color(fn (int $state): string => $state > 0 ? 'success' : 'danger')
->sortable(),
TextColumn::make('member_count')
->label('Members')
->sortable(),
TextColumn::make('tenant_count')
->label('Tenants')
->sortable(),
TextColumn::make('updated_at')
->label('Last activity')
->since()
->sortable(),
])
->emptyStateHeading('No workspaces')
->emptyStateDescription('No workspaces exist in the system yet.')
->bulkActions([]);
}
/**
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
*/
public function getRecentBreakGlassActions(): array
{
return AuditLog::query()
->where('action', 'like', '%break_glass%')
->orderByDesc('recorded_at')
->limit(10)
->get()
->map(fn (AuditLog $log): array => [
'action' => (string) $log->action,
'actor' => $log->actor_email ?: 'Unknown',
'workspace' => $log->metadata['metadata']['workspace_id'] ?? null
? Workspace::query()->whereKey((int) $log->metadata['metadata']['workspace_id'])->value('name')
: null,
'recorded_at' => $log->recorded_at?->diffForHumans() ?? 'Unknown',
])
->all();
}
/** /**
* @return array<Action> * @return array<Action>
*/ */
@ -156,8 +49,7 @@ protected function getHeaderActions(): array
return [ return [
Action::make('assign_owner') Action::make('assign_owner')
->label('Emergency: Assign Owner') ->label('Assign owner (break-glass)')
->icon('heroicon-o-shield-exclamation')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Assign workspace owner') ->modalHeading('Assign workspace owner')
@ -271,8 +163,7 @@ protected function getHeaderActions(): array
->success() ->success()
->send(); ->send();
}) })
->disabled(fn (): bool => ! $breakGlass->isActive()) ->disabled(fn (): bool => ! $breakGlass->isActive()),
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first.' : null),
]; ];
} }
} }

View File

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Security;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class AccessLogs extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Access logs';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|\UnitEnum|null $navigationGroup = 'Security';
protected static ?string $slug = 'security/access-logs';
protected string $view = 'filament.system.pages.security.access-logs';
public static function canAccess(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
public function table(Table $table): Table
{
return $table
->defaultSort('recorded_at', 'desc')
->query(function (): Builder {
return AuditLog::query()
->where(function (Builder $query): void {
$query
->where('action', 'platform.auth.login')
->orWhere('action', 'like', 'platform.break_glass.%');
});
})
->columns([
TextColumn::make('recorded_at')
->label('Recorded')
->since(),
TextColumn::make('action')
->label('Action')
->searchable(),
TextColumn::make('status')
->badge()
->color(fn (?string $state): string => $state === 'failure' ? 'danger' : 'success'),
TextColumn::make('actor_email')
->label('Actor')
->formatStateUsing(fn (?string $state): string => $state ?: 'Unknown'),
])
->emptyStateHeading('No access logs found')
->emptyStateDescription('Platform login and break-glass events will appear here.');
}
}

View File

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\SystemConsole\StuckRunClassifier;
use Carbon\CarbonImmutable;
use Filament\Widgets\Widget;
class ControlTowerHealthIndicator extends Widget
{
protected string $view = 'filament.system.widgets.control-tower-health-indicator';
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* @return array{level: string, color: string, icon: string, label: string, failed: int, stuck: int}
*/
public function getHealthData(): array
{
$now = CarbonImmutable::now();
$last24h = $now->subHours(24);
$failedRuns = OperationRun::query()
->where('created_at', '>=', $last24h)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->count();
$stuckRuns = app(StuckRunClassifier::class)
->apply(OperationRun::query())
->count();
if ($failedRuns > 0 || $stuckRuns > 0) {
$level = ($failedRuns >= 5 || $stuckRuns >= 3) ? 'critical' : 'warning';
} else {
$level = 'healthy';
}
return match ($level) {
'critical' => [
'level' => 'critical',
'color' => 'danger',
'icon' => 'heroicon-o-x-circle',
'label' => 'Critical',
'failed' => $failedRuns,
'stuck' => $stuckRuns,
],
'warning' => [
'level' => 'warning',
'color' => 'warning',
'icon' => 'heroicon-o-exclamation-triangle',
'label' => 'Attention needed',
'failed' => $failedRuns,
'stuck' => $stuckRuns,
],
default => [
'level' => 'healthy',
'color' => 'success',
'icon' => 'heroicon-o-check-circle',
'label' => 'All systems healthy',
'failed' => 0,
'stuck' => 0,
],
};
}
}

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\StuckRunClassifier;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ControlTowerKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
$totalRuns = (clone $baseQuery)->count();
$activeRuns = (clone $baseQuery)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->count();
$failedRuns = (clone $baseQuery)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->count();
$stuckRuns = app(StuckRunClassifier::class)
->apply((clone $baseQuery))
->count();
return [
Stat::make('Runs in window', $totalRuns)
->description($window::options()[$window->value] ?? 'Last 24 hours')
->url(SystemOperationRunLinks::index()),
Stat::make('Active', $activeRuns)
->color($activeRuns > 0 ? 'warning' : 'gray')
->url(SystemOperationRunLinks::index()),
Stat::make('Failed', $failedRuns)
->color($failedRuns > 0 ? 'danger' : 'gray')
->url(SystemOperationRunLinks::index()),
Stat::make('Stuck', $stuckRuns)
->color($stuckRuns > 0 ? 'danger' : 'gray')
->url(SystemOperationRunLinks::index()),
];
}
}

View File

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
class ControlTowerRecentFailures extends Widget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
/** @var Collection<int, OperationRun> $runs */
$runs = OperationRun::query()
->with('tenant')
->where('created_at', '>=', $start)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->latest('id')
->limit(8)
->get();
return [
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
'runs' => $runs->map(function (OperationRun $run): array {
$failureSummary = is_array($run->failure_summary) ? $run->failure_summary : [];
$primaryFailure = is_array($failureSummary[0] ?? null) ? $failureSummary[0] : [];
$failureMessage = trim((string) ($primaryFailure['message'] ?? ''));
return [
'id' => (int) $run->getKey(),
'operation' => OperationCatalog::label((string) $run->type),
'tenant' => $run->tenant?->name ?? 'Tenantless',
'created_at' => $run->created_at?->diffForHumans() ?? '—',
'failure_message' => $failureMessage !== '' ? $failureMessage : 'No failure details available',
'url' => SystemOperationRunLinks::view($run),
];
}),
'runsUrl' => SystemOperationRunLinks::index(),
];
}
}

View File

@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
class ControlTowerTopOffenders extends Widget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
/** @var Collection<int, OperationRun> $grouped */
$grouped = OperationRun::query()
->selectRaw('workspace_id, tenant_id, type, COUNT(*) AS failed_count')
->where('created_at', '>=', $start)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->groupBy('workspace_id', 'tenant_id', 'type')
->orderByDesc('failed_count')
->limit(10)
->get();
$workspaceIds = $grouped
->pluck('workspace_id')
->filter(fn ($value): bool => is_numeric($value))
->map(fn ($value): int => (int) $value)
->unique()
->values()
->all();
$tenantIds = $grouped
->pluck('tenant_id')
->filter(fn ($value): bool => is_numeric($value))
->map(fn ($value): int => (int) $value)
->unique()
->values()
->all();
$workspaceNames = Workspace::query()
->whereIn('id', $workspaceIds)
->pluck('name', 'id')
->all();
$tenantNames = Tenant::query()
->whereIn('id', $tenantIds)
->pluck('name', 'id')
->all();
return [
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
'offenders' => $grouped->map(function (OperationRun $record) use ($workspaceNames, $tenantNames): array {
$workspaceId = is_numeric($record->workspace_id) ? (int) $record->workspace_id : null;
$tenantId = is_numeric($record->tenant_id) ? (int) $record->tenant_id : null;
return [
'workspace_label' => $workspaceId !== null
? ($workspaceNames[$workspaceId] ?? ('Workspace #'.$workspaceId))
: 'Unknown workspace',
'tenant_label' => $tenantId !== null
? ($tenantNames[$tenantId] ?? ('Tenant #'.$tenantId))
: 'Tenantless',
'operation_label' => OperationCatalog::label((string) $record->type),
'failed_count' => (int) $record->getAttribute('failed_count'),
];
}),
'runsUrl' => SystemOperationRunLinks::index(),
];
}
}

View File

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\WorkspaceRole;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class RepairWorkspaceOwnersStats extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$totalWorkspaces = Workspace::query()->count();
$workspacesWithOwners = WorkspaceMembership::query()
->where('role', WorkspaceRole::Owner->value)
->distinct('workspace_id')
->count('workspace_id');
$ownerlessWorkspaces = $totalWorkspaces - $workspacesWithOwners;
$totalMembers = WorkspaceMembership::query()->count();
return [
Stat::make('Total workspaces', $totalWorkspaces)
->color('gray')
->icon('heroicon-o-rectangle-stack'),
Stat::make('Healthy (has owner)', $workspacesWithOwners)
->color($workspacesWithOwners > 0 ? 'success' : 'gray')
->icon('heroicon-o-check-circle'),
Stat::make('Ownerless', $ownerlessWorkspaces)
->color($ownerlessWorkspaces > 0 ? 'danger' : 'success')
->icon($ownerlessWorkspaces > 0 ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle'),
Stat::make('Total memberships', $totalMembers)
->color('gray')
->icon('heroicon-o-users'),
];
}
}

View File

@ -12,8 +12,8 @@
use App\Models\AlertRule; use App\Models\AlertRule;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -96,7 +96,7 @@ private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder
->where('workspace_id', $workspaceId) ->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')); ->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'));
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); $activeTenant = Filament::getTenant();
if ($activeTenant instanceof Tenant) { if ($activeTenant instanceof Tenant) {
$query->where('tenant_id', (int) $activeTenant->getKey()); $query->where('tenant_id', (int) $activeTenant->getKey());

View File

@ -4,9 +4,9 @@
namespace App\Filament\Widgets\Dashboard; namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineCompareRun;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
@ -69,12 +69,11 @@ protected function getViewData(): array
->where('severity', Finding::SEVERITY_LOW) ->where('severity', Finding::SEVERITY_LOW)
->count(); ->count();
$latestRun = OperationRun::query() $latestRun = BaselineCompareRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare') ->where('baseline_profile_id', $profile->getKey())
->where('context->baseline_profile_id', (string) $profile->getKey()) ->whereNotNull('finished_at')
->whereNotNull('completed_at') ->latest('finished_at')
->latest('completed_at')
->first(); ->first();
return [ return [

View File

@ -1,178 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Jobs\ScanEntraAdminRolesJob;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class AdminRolesSummaryWidget extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.admin-roles-summary';
public ?Tenant $record = null;
private function resolveTenant(): ?Tenant
{
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant) {
return $tenant;
}
return $this->record instanceof Tenant ? $this->record : null;
}
public function scanNow(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->resolveTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can(Capabilities::ENTRA_ROLES_MANAGE, $tenant)) {
abort(403);
}
/** @var OperationRunService $operationRuns */
$operationRuns = app(OperationRunService::class);
$opRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'entra.admin_roles.scan',
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'trigger' => 'scan',
],
context: [
'workspace_id' => (int) $tenant->workspace_id,
'initiator_user_id' => (int) $user->getKey(),
],
initiator: $user,
);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
ScanEntraAdminRolesJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->body('The scan will run in the background. Results appear once complete.')
->actions([
Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = $this->resolveTenant();
if (! $tenant instanceof Tenant) {
return $this->emptyState();
}
$user = auth()->user();
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::ENTRA_ROLES_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::ENTRA_ROLES_MANAGE, $tenant);
$report = StoredReport::query()
->where('tenant_id', (int) $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->orderByDesc('created_at')
->first();
if (! $report instanceof StoredReport) {
return [
'tenant' => $tenant,
'reportSummary' => null,
'lastScanAt' => null,
'highPrivilegeCount' => 0,
'canManage' => $canManage,
'canView' => $canView,
'viewReportUrl' => null,
];
}
$payload = is_array($report->payload) ? $report->payload : [];
$totals = is_array($payload['totals'] ?? null) ? $payload['totals'] : [];
$highPrivilegeCount = (int) ($totals['high_privilege_assignments'] ?? 0);
return [
'tenant' => $tenant,
'reportSummary' => $totals,
'lastScanAt' => $report->created_at?->diffForHumans() ?? '—',
'highPrivilegeCount' => $highPrivilegeCount,
'canManage' => $canManage,
'canView' => $canView,
'viewReportUrl' => null,
];
}
/**
* @return array<string, mixed>
*/
private function emptyState(): array
{
return [
'tenant' => null,
'reportSummary' => null,
'lastScanAt' => null,
'highPrivilegeCount' => 0,
'canManage' => false,
'canView' => false,
'viewReportUrl' => null,
];
}
}

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class TenantReviewPackCard extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.tenant-review-pack-card';
public ?Tenant $record = null;
private function resolveTenant(): ?Tenant
{
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant) {
return $tenant;
}
return $this->record instanceof Tenant ? $this->record : null;
}
public function generatePack(bool $includePii = true, bool $includeOperations = true): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->resolveTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
abort(403);
}
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$activeRun = $service->checkActiveRun($tenant)
? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->active()
->orderByDesc('id')
->first()
: null;
if ($activeRun) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $activeRun->type)
->body('A review pack is already queued or running for this tenant.')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::tenantlessView($activeRun)),
])
->send();
return;
}
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
$runUrl = $reviewPack->operationRun
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The pack will be generated in the background. You will be notified when it is ready.');
if ($runUrl !== null) {
$toast->actions([
Action::make('view_run')
->label('View run')
->url($runUrl),
]);
}
$toast->send();
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = $this->resolveTenant();
if (! $tenant instanceof Tenant) {
return $this->emptyState();
}
$user = auth()->user();
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$latestPack = ReviewPack::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at')
->orderByDesc('id')
->first();
if (! $latestPack instanceof ReviewPack) {
return [
'tenant' => $tenant,
'pack' => null,
'statusEnum' => null,
'canView' => $canView,
'canManage' => $canManage,
'downloadUrl' => null,
'failedReason' => null,
];
}
$statusEnum = ReviewPackStatus::tryFrom((string) $latestPack->status);
$downloadUrl = null;
if ($statusEnum === ReviewPackStatus::Ready && $canView) {
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$downloadUrl = $service->generateDownloadUrl($latestPack);
}
$failedReason = null;
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
}
return [
'tenant' => $tenant,
'pack' => $latestPack,
'statusEnum' => $statusEnum,
'canView' => $canView,
'canManage' => $canManage,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
];
}
/**
* @return array<string, mixed>
*/
private function emptyState(): array
{
return [
'tenant' => null,
'pack' => null,
'statusEnum' => null,
'canView' => false,
'canManage' => false,
'downloadUrl' => null,
'failedReason' => null,
];
}
}

View File

@ -13,8 +13,6 @@
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -70,8 +68,6 @@ public function startVerification(StartVerification $verification): void
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Another operation is already running')
->body('Please wait for the active run to finish.') ->body('Please wait for the active run to finish.')
@ -87,9 +83,10 @@ public function startVerification(StartVerification $verification): void
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Verification already running')
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->body('A verification run is already queued or running.')
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -143,9 +140,9 @@ public function startVerification(StartVerification $verification): void
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make()
->title('Verification started')
OperationUxPresenter::queuedToast((string) $result->run->type) ->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ReviewPack;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ReviewPackDownloadController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
{
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException;
}
if ($reviewPack->expires_at && $reviewPack->expires_at->isPast()) {
throw new NotFoundHttpException;
}
$disk = Storage::disk($reviewPack->file_disk ?? 'exports');
if (! $disk->exists($reviewPack->file_path)) {
throw new NotFoundHttpException;
}
$tenant = $reviewPack->tenant;
$filename = sprintf(
'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown',
$reviewPack->generated_at?->format('Y-m-d') ?? now()->format('Y-m-d'),
);
return $disk->download($reviewPack->file_path, $filename, [
'X-Review-Pack-SHA256' => $reviewPack->sha256 ?? '',
]);
}
}

View File

@ -4,13 +4,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl; use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -44,37 +43,32 @@ public function __invoke(Request $request): RedirectResponse
abort(404); abort(404);
} }
$prevWorkspaceId = $context->currentWorkspaceId($request);
$context->setCurrentWorkspace($workspace, $user, $request); $context->setCurrentWorkspace($workspace, $user, $request);
/** @var WorkspaceAuditLogger $auditLogger */
$auditLogger = app(WorkspaceAuditLogger::class);
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSelected->value,
context: [
'metadata' => [
'method' => 'manual',
'reason' => 'context_bar',
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume($request); $intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) { if ($intendedUrl !== null) {
return redirect()->to($intendedUrl); return redirect()->to($intendedUrl);
} }
/** @var WorkspaceRedirectResolver $resolver */ $tenantsQuery = $user->tenants()
$resolver = app(WorkspaceRedirectResolver::class); ->where('workspace_id', $workspace->getKey())
->where('status', 'active');
return redirect()->to($resolver->resolve($workspace, $user)); $tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.onboarding');
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
}
return redirect()->to(ChooseTenant::getUrl());
} }
} }

View File

@ -29,7 +29,7 @@ public function handle(Request $request, Closure $next, string $capability): Res
} }
if (! Gate::forUser($user)->allows($capability)) { if (! Gate::forUser($user)->allows($capability)) {
abort(403); abort(404);
} }
return $next($request); return $next($request);

View File

@ -1,20 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl; use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Closure; use Closure;
use Filament\Notifications\Notification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected class EnsureWorkspaceSelected
@ -22,20 +19,10 @@ class EnsureWorkspaceSelected
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* Spec 107 7-step algorithm:
* 1. If workspace-optional path allow
* 2. If ?choose=1 redirect to chooser
* 3. If session set validate membership; stale clear + warn + chooser
* 4. Load selectable memberships
* 5. If exactly 1 auto-select + audit + redirect via tenant branching
* 6. If last_workspace_id valid auto-select + audit + redirect
* 7. Else redirect to chooser
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
// Auth-related routes are always allowed.
$routeName = $request->route()?->getName(); $routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) { if (is_string($routeName) && str_contains($routeName, '.auth.')) {
@ -44,12 +31,10 @@ public function handle(Request $request, Closure $next): Response
$path = '/'.ltrim($request->path(), '/'); $path = '/'.ltrim($request->path(), '/');
// --- Step 1: workspace-optional bypass ---
if ($this->isWorkspaceOptionalPath($request, $path)) { if ($this->isWorkspaceOptionalPath($request, $path)) {
return $next($request); return $next($request);
} }
// Tenant-scoped routes are handled separately.
if (str_starts_with($path, '/admin/t/')) { if (str_starts_with($path, '/admin/t/')) {
return $next($request); return $next($request);
} }
@ -63,105 +48,44 @@ public function handle(Request $request, Closure $next): Response
/** @var WorkspaceContext $context */ /** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class); $context = app(WorkspaceContext::class);
// --- Step 2: forced chooser via ?choose=1 --- $workspace = $context->resolveInitialWorkspaceFor($user, $request);
if ($request->query('choose') === '1') {
return $this->redirectToChooser(); if ($workspace !== null) {
return $next($request);
} }
// --- Step 3: validate active session --- $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$currentId = $context->currentWorkspaceId($request);
if ($currentId !== null) { $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
$workspace = Workspace::query()->whereKey($currentId)->first(); ? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
if ( $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
$workspace instanceof Workspace
&& empty($workspace->archived_at)
&& $context->isMember($user, $workspace)
) {
return $next($request);
}
// Stale session — clear and warn. if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
$this->clearStaleSession($context, $user, $request, $workspace); abort(404);
return $this->redirectToChooser();
} }
// --- Step 4: load selectable workspace memberships --- if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
$selectableMemberships = WorkspaceMembership::query() abort(404);
->where('user_id', $user->getKey())
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->select('workspace_memberships.*')
->get();
// --- Step 5: single membership auto-resume ---
if ($selectableMemberships->count() === 1) {
/** @var WorkspaceMembership $membership */
$membership = $selectableMemberships->first();
$workspace = Workspace::query()->whereKey($membership->workspace_id)->first();
if ($workspace instanceof Workspace) {
$context->setCurrentWorkspace($workspace, $user, $request);
$this->emitAuditEvent(
workspace: $workspace,
user: $user,
actionId: AuditActionId::WorkspaceAutoSelected,
method: 'auto',
reason: 'single_membership',
);
return $this->redirectViaTenantBranching($workspace, $user);
}
} }
// --- Step 6: last_workspace_id auto-resume --- if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/provider-connections')) {
if ($user->last_workspace_id !== null) { abort(404);
$lastWorkspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (
$lastWorkspace instanceof Workspace
&& empty($lastWorkspace->archived_at)
&& $context->isMember($user, $lastWorkspace)
) {
$context->setCurrentWorkspace($lastWorkspace, $user, $request);
$this->emitAuditEvent(
workspace: $lastWorkspace,
user: $user,
actionId: AuditActionId::WorkspaceAutoSelected,
method: 'auto',
reason: 'last_used',
);
return $this->redirectViaTenantBranching($lastWorkspace, $user);
}
// Stale last_workspace_id — clear and warn.
$workspaceName = $lastWorkspace?->name;
$user->forceFill(['last_workspace_id' => null])->save();
if ($workspaceName !== null) {
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->danger()
->send();
}
} }
// --- Step 7: fallback to chooser --- $target = ($hasAnyActiveMembership || $canCreateWorkspace)
if ($selectableMemberships->isNotEmpty()) {
WorkspaceIntendedUrl::storeFromRequest($request);
}
$canCreate = $user->can('create', Workspace::class);
$target = ($selectableMemberships->isNotEmpty() || $canCreate)
? '/admin/choose-workspace' ? '/admin/choose-workspace'
: '/admin/no-access'; : '/admin/no-access';
return new \Illuminate\Http\Response('', 302, ['Location' => $target]); if ($target === '/admin/choose-workspace') {
WorkspaceIntendedUrl::storeFromRequest($request);
}
return new HttpResponse('', 302, ['Location' => $target]);
} }
private function isWorkspaceOptionalPath(Request $request, string $path): bool private function isWorkspaceOptionalPath(Request $request, string $path): bool
@ -186,64 +110,12 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
} }
private function redirectToChooser(): Response private function isOperateHubPath(string $path): bool
{ {
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); return in_array($path, [
} '/admin/operations',
'/admin/alerts',
private function redirectViaTenantBranching(Workspace $workspace, User $user): Response '/admin/audit-log',
{ ], true);
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$url = $resolver->resolve($workspace, $user);
return new \Illuminate\Http\Response('', 302, ['Location' => $url]);
}
private function clearStaleSession(WorkspaceContext $context, User $user, Request $request, ?Workspace $workspace): void
{
$workspaceName = $workspace?->name;
$session = $request->hasSession() ? $request->session() : session();
$session->forget(WorkspaceContext::SESSION_KEY);
if ($user->last_workspace_id !== null && $context->currentWorkspaceId($request) === null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
if ($workspaceName !== null) {
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->danger()
->send();
}
}
private function emitAuditEvent(
Workspace $workspace,
User $user,
AuditActionId $actionId,
string $method,
string $reason,
?int $prevWorkspaceId = null,
): void {
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => [
'method' => $method,
'reason' => $reason,
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
} }
} }

View File

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class UseSystemSessionCookie
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$originalCookieName = (string) config('session.cookie');
config(['session.cookie' => $this->systemCookieName()]);
try {
return $next($request);
} finally {
config(['session.cookie' => $originalCookieName]);
}
}
private function systemCookieName(): string
{
return Str::slug((string) config('app.name', 'laravel')).'-system-session';
}
}

View File

@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class UseSystemSessionCookieForLivewireRequests
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! $this->shouldUseSystemCookie($request)) {
return $next($request);
}
$originalCookieName = (string) config('session.cookie');
config(['session.cookie' => $this->systemCookieName()]);
try {
return $next($request);
} finally {
config(['session.cookie' => $originalCookieName]);
}
}
private function shouldUseSystemCookie(Request $request): bool
{
if (
! $request->is('livewire-*/update')
&& ! $request->is('livewire-*/upload-file')
&& ! $request->is('livewire-*/preview-file/*')
) {
return false;
}
if ($this->snapshotIndicatesSystemPanel($request)) {
return true;
}
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
return $refererPath === '/system' || str_starts_with($refererPath, '/system/');
}
private function snapshotIndicatesSystemPanel(Request $request): bool
{
if (! $request->is('livewire-*/update')) {
return false;
}
$components = $request->input('components');
if (! is_array($components)) {
return false;
}
foreach ($components as $componentPayload) {
if (! is_array($componentPayload)) {
continue;
}
$snapshot = $componentPayload['snapshot'] ?? null;
if (! is_string($snapshot) || $snapshot === '') {
continue;
}
$decodedSnapshot = json_decode($snapshot, associative: true);
if (! is_array($decodedSnapshot)) {
continue;
}
$path = $decodedSnapshot['memo']['path'] ?? null;
if (! is_string($path) || $path === '') {
continue;
}
$path = '/'.ltrim($path, '/');
if ($path === '/system' || str_starts_with($path, '/system/')) {
return true;
}
}
return false;
}
private function systemCookieName(): string
{
return Str::slug((string) config('app.name', 'laravel')).'-system-session';
}
}

View File

@ -13,7 +13,9 @@
use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\SnapshotValidator; use App\Services\Intune\SnapshotValidator;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@ -463,6 +465,48 @@ public function handle(
failures: $runFailuresForOperationRun, failures: $runFailuresForOperationRun,
); );
if (! $initiator instanceof User) {
return;
}
$message = "Added {$succeeded} policies";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if ($includeFoundations) {
$message .= ". Foundations: {$foundationMutations} items";
if ($foundationFailures > 0) {
$message .= " ({$foundationFailures} failed)";
}
}
$message .= '.';
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
$notification = Notification::make()
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
->body($message)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
if ($partial) {
$notification->warning();
} else {
$notification->success();
}
$notification
->sendToDatabase($initiator)
->send();
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$this->failRun( $this->failRun(
operationRunService: $operationRunService, operationRunService: $operationRunService,
@ -510,6 +554,31 @@ private function failRun(
]], ]],
); );
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
}
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
{
if (! $initiator instanceof User) {
return;
}
$notification = Notification::make()
->title('Add Policies Failed')
->body($reason);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
$notification
->danger()
->sendToDatabase($initiator)
->send();
} }
private function mapGraphFailureReasonCode(?int $status): string private function mapGraphFailureReasonCode(?int $status): string

View File

@ -4,7 +4,6 @@
namespace App\Jobs\Alerts; namespace App\Jobs\Alerts;
use App\Models\AlertRule;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Workspace; use App\Models\Workspace;
@ -58,10 +57,7 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
try { try {
$events = [ $events = [
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
]; ];
$createdDeliveries = 0; $createdDeliveries = 0;
@ -158,28 +154,8 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart)
->where('workspace_id', $workspaceId) ->where('workspace_id', $workspaceId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL])
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where('status', Finding::STATUS_NEW)
->where(function ($query) use ($windowStart): void { ->where('created_at', '>', $windowStart)
$query
->where(function ($statusQuery) use ($windowStart): void {
$statusQuery
->where('status', Finding::STATUS_NEW)
->where('created_at', '>', $windowStart);
})
->orWhere(function ($statusQuery) use ($windowStart): void {
$statusQuery
->where('status', Finding::STATUS_REOPENED)
->where(function ($reopenedQuery) use ($windowStart): void {
$reopenedQuery
->where('reopened_at', '>', $windowStart)
->orWhere(function ($fallbackQuery) use ($windowStart): void {
$fallbackQuery
->whereNull('reopened_at')
->where('updated_at', '>', $windowStart);
});
});
});
})
->orderBy('id') ->orderBy('id')
->get(); ->get();
@ -246,130 +222,6 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt
return $events; return $events;
} }
/**
* @return array<int, array<string, mixed>>
*/
private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$now = CarbonImmutable::now('UTC');
$newlyOverdueTenantIds = Finding::query()
->where('workspace_id', $workspaceId)
->whereNotNull('tenant_id')
->whereNotNull('due_at')
->where('due_at', '>', $windowStart)
->where('due_at', '<=', $now)
->whereIn('status', Finding::openStatusesForQuery())
->orderBy('tenant_id')
->pluck('tenant_id')
->map(static fn (mixed $value): int => (int) $value)
->filter(static fn (int $tenantId): bool => $tenantId > 0)
->unique()
->values()
->all();
if ($newlyOverdueTenantIds === []) {
return [];
}
$severityRank = [
Finding::SEVERITY_LOW => 1,
Finding::SEVERITY_MEDIUM => 2,
Finding::SEVERITY_HIGH => 3,
Finding::SEVERITY_CRITICAL => 4,
];
/** @var array<int, array{overdue_total:int, overdue_by_severity:array<string, int>, severity:string}> $summaryByTenant */
$summaryByTenant = [];
foreach ($newlyOverdueTenantIds as $tenantId) {
$summaryByTenant[$tenantId] = [
'overdue_total' => 0,
'overdue_by_severity' => [
Finding::SEVERITY_CRITICAL => 0,
Finding::SEVERITY_HIGH => 0,
Finding::SEVERITY_MEDIUM => 0,
Finding::SEVERITY_LOW => 0,
],
'severity' => Finding::SEVERITY_LOW,
];
}
$overdueFindings = Finding::query()
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $newlyOverdueTenantIds)
->whereNotNull('due_at')
->where('due_at', '<=', $now)
->whereIn('status', Finding::openStatusesForQuery())
->orderBy('tenant_id')
->orderBy('id')
->get(['tenant_id', 'severity']);
foreach ($overdueFindings as $finding) {
$tenantId = (int) ($finding->tenant_id ?? 0);
if (! isset($summaryByTenant[$tenantId])) {
continue;
}
$severity = strtolower(trim((string) $finding->severity));
if (! array_key_exists($severity, $severityRank)) {
$severity = Finding::SEVERITY_HIGH;
}
$summaryByTenant[$tenantId]['overdue_total']++;
$summaryByTenant[$tenantId]['overdue_by_severity'][$severity]++;
$currentSeverity = $summaryByTenant[$tenantId]['severity'];
if (($severityRank[$severity] ?? 0) > ($severityRank[$currentSeverity] ?? 0)) {
$summaryByTenant[$tenantId]['severity'] = $severity;
}
}
$windowFingerprint = $windowStart->setTimezone('UTC')->format('Uu');
$events = [];
foreach ($newlyOverdueTenantIds as $tenantId) {
$summary = $summaryByTenant[$tenantId] ?? null;
if (! is_array($summary) || (int) ($summary['overdue_total'] ?? 0) <= 0) {
continue;
}
/** @var array<string, int> $counts */
$counts = $summary['overdue_by_severity'];
$events[] = [
'event_type' => AlertRule::EVENT_SLA_DUE,
'tenant_id' => $tenantId,
'severity' => (string) ($summary['severity'] ?? Finding::SEVERITY_HIGH),
'fingerprint_key' => sprintf('sla_due:tenant:%d:window:%s', $tenantId, $windowFingerprint),
'title' => 'SLA due findings detected',
'body' => sprintf(
'%d open finding(s) are overdue (critical: %d, high: %d, medium: %d, low: %d).',
(int) $summary['overdue_total'],
(int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0),
(int) ($counts[Finding::SEVERITY_HIGH] ?? 0),
(int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0),
(int) ($counts[Finding::SEVERITY_LOW] ?? 0),
),
'metadata' => [
'overdue_total' => (int) $summary['overdue_total'],
'overdue_by_severity' => [
Finding::SEVERITY_CRITICAL => (int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0),
Finding::SEVERITY_HIGH => (int) ($counts[Finding::SEVERITY_HIGH] ?? 0),
Finding::SEVERITY_MEDIUM => (int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0),
Finding::SEVERITY_LOW => (int) ($counts[Finding::SEVERITY_LOW] ?? 0),
],
],
];
}
return $events;
}
private function firstFailureMessage(OperationRun $run): string private function firstFailureMessage(OperationRun $run): string
{ {
$failures = is_array($run->failure_summary) ? $run->failure_summary : []; $failures = is_array($run->failure_summary) ? $run->failure_summary : [];
@ -401,84 +253,4 @@ private function sanitizeErrorMessage(Throwable $exception): string
return mb_substr($message, 0, 500); return mb_substr($message, 0, 500);
} }
/**
* @return array<int, array<string, mixed>>
*/
private function permissionMissingEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$findings = Finding::query()
->where('workspace_id', $workspaceId)
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->where('updated_at', '>', $windowStart)
->orderBy('id')
->get();
$events = [];
foreach ($findings as $finding) {
$events[] = [
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
'tenant_id' => (int) $finding->tenant_id,
'severity' => (string) $finding->severity,
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
'title' => 'Missing permission detected',
'body' => sprintf(
'Permission "%s" is missing for tenant %d (severity: %s).',
(string) ($finding->evidence_jsonb['permission_key'] ?? $finding->subject_external_id ?? 'unknown'),
(int) $finding->tenant_id,
(string) $finding->severity,
),
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'permission_key' => (string) ($finding->evidence_jsonb['permission_key'] ?? ''),
],
];
}
return $events;
}
/**
* @return array<int, array<string, mixed>>
*/
private function entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$findings = Finding::query()
->where('workspace_id', $workspaceId)
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL])
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->where('updated_at', '>', $windowStart)
->orderBy('id')
->get();
$events = [];
foreach ($findings as $finding) {
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$events[] = [
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
'tenant_id' => (int) $finding->tenant_id,
'severity' => (string) $finding->severity,
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
'title' => 'High-privilege Entra admin role detected',
'body' => sprintf(
'Role "%s" assigned to %s (severity: %s).',
(string) ($evidence['role_display_name'] ?? 'unknown'),
(string) ($evidence['principal_display_name'] ?? $finding->subject_external_id ?? 'unknown'),
(string) $finding->severity,
),
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'role_display_name' => (string) ($evidence['role_display_name'] ?? ''),
'principal_display_name' => (string) ($evidence['principal_display_name'] ?? ''),
],
];
}
return $events;
}
} }

View File

@ -6,7 +6,6 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -21,7 +20,7 @@ class ApplyBackupScheduleRetentionJob implements ShouldQueue
public function __construct(public int $backupScheduleId) {} public function __construct(public int $backupScheduleId) {}
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver, OperationRunService $operationRunService): void public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void
{ {
$schedule = BackupSchedule::query() $schedule = BackupSchedule::query()
->with(['tenant.workspace']) ->with(['tenant.workspace'])
@ -133,18 +132,18 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
}); });
} }
$operationRunService->updateRun( $operationRun->update([
$operationRun, 'status' => OperationRunStatus::Completed->value,
status: OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value,
outcome: OperationRunOutcome::Succeeded->value, 'summary_counts' => [
summaryCounts: [
'total' => (int) $deleteBackupSetIds->count(), 'total' => (int) $deleteBackupSetIds->count(),
'processed' => (int) $deleteBackupSetIds->count(), 'processed' => (int) $deleteBackupSetIds->count(),
'succeeded' => $deletedCount, 'succeeded' => $deletedCount,
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount), 'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
'updated' => $deletedCount, 'updated' => $deletedCount,
], ],
); 'completed_at' => now(),
]);
$auditLogger->log( $auditLogger->log(
tenant: $schedule->tenant, tenant: $schedule->tenant,

View File

@ -1,395 +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_RESOLVED,
'resolved_at' => $backfillStartedAt,
'resolved_reason' => 'consolidated_duplicate',
'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,375 +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_RESOLVED,
'resolved_at' => $backfillStartedAt,
'resolved_reason' => 'consolidated_duplicate',
'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

@ -10,8 +10,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -114,6 +116,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
@ -148,6 +165,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
@ -197,6 +229,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
} }
@ -231,6 +278,27 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($succeeded > 0 || $failed > 0) {
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Export Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
} catch (Throwable $e) { } catch (Throwable $e) {
if ($this->operationRun) { if ($this->operationRun) {
$operationRunService->updateRun( $operationRunService->updateRun(
@ -243,6 +311,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if (isset($user) && $user instanceof User) {
Notification::make()
->title('Bulk Export Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
throw $e; throw $e;
} }
} }

View File

@ -8,8 +8,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -127,6 +129,32 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
$message = "Restored {$succeeded} policies";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Restore Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
} catch (Throwable $e) { } catch (Throwable $e) {
if ($this->operationRun) { if ($this->operationRun) {
$operationRunService->updateRun( $operationRunService->updateRun(
@ -139,6 +167,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if (isset($user) && $user instanceof User) {
Notification::make()
->title('Bulk Restore Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
throw $e; throw $e;
} }
} }

View File

@ -8,8 +8,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -102,6 +104,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
@ -141,6 +158,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
} }
@ -173,5 +205,39 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
$message = "Force deleted {$succeeded} restore runs";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Force Delete Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
} }
} }

View File

@ -8,8 +8,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -101,6 +103,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
@ -139,6 +156,21 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
if ($user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return; return;
} }
} }
@ -170,5 +202,39 @@ public function handle(OperationRunService $operationRunService): void
); );
} }
$message = "Restored {$succeeded} restore runs";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Restore Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
} }
} }

View File

@ -2,11 +2,11 @@
namespace App\Jobs; namespace App\Jobs;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Listeners\SyncRestoreRunToOperationRun; use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
@ -58,6 +58,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
return; return;
} }
$this->notifyStatus($restoreRun, 'queued');
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun);
$tenant = $restoreRun->tenant; $tenant = $restoreRun->tenant;
@ -72,6 +73,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
$this->notifyStatus($restoreRun->refresh(), 'failed');
if ($tenant) { if ($tenant) {
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -94,31 +97,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
return; return;
} }
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
$restoreRun->update([
'status' => RestoreRunStatus::Failed->value,
'failure_reason' => $e->reasonMessage,
'completed_at' => CarbonImmutable::now(),
]);
if ($this->operationRun) {
app(\App\Services\OperationRunService::class)->updateRun(
$this->operationRun,
status: \App\Support\OperationRunStatus::Completed->value,
outcome: \App\Support\OperationRunOutcome::Failed->value,
failures: [[
'code' => 'hardening.write_blocked',
'reason_code' => $e->reasonCode,
'message' => $e->reasonMessage,
]],
);
}
return;
}
$restoreRun->update([ $restoreRun->update([
'status' => RestoreRunStatus::Running->value, 'status' => RestoreRunStatus::Running->value,
'started_at' => CarbonImmutable::now(), 'started_at' => CarbonImmutable::now(),
@ -129,6 +107,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
// code performs restore-run updates without firing model events. // code performs restore-run updates without firing model events.
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
$this->notifyStatus($restoreRun->refresh(), 'running');
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'restore.started', action: 'restore.started',
@ -156,6 +136,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$restoreRun->refresh(); $restoreRun->refresh();
@ -171,6 +152,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
if ($tenant) { if ($tenant) {
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -193,4 +176,45 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
throw $throwable; throw $throwable;
} }
} }
private function notifyStatus(RestoreRun $restoreRun, string $status): void
{
$email = $this->actorEmail;
if (! is_string($email) || $email === '') {
$email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null;
}
if (! is_string($email) || $email === '') {
return;
}
$user = User::query()->where('email', $email)->first();
if (! $user) {
return;
}
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
$counts = [];
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
$counts[$key] = (int) $metadata[$key];
}
}
$payload = [
'tenant_id' => (int) $restoreRun->tenant_id,
'run_type' => 'restore',
'run_id' => (int) $restoreRun->getKey(),
'status' => $status,
];
if ($counts !== []) {
$payload['counts'] = $counts;
}
$user->notify(new RunStatusChangedNotification($payload));
}
} }

View File

@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\PermissionPosture\FindingGeneratorContract;
use App\Support\OperationCatalog;
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;
use RuntimeException;
use Throwable;
class GeneratePermissionPostureFindingsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $tenantId,
public readonly array $permissionComparison,
) {}
public function handle(
FindingGeneratorContract $generator,
OperationRunService $operationRuns,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found: '.$this->tenantId);
}
// FR-016: Skip if tenant has no active provider connection
if ($tenant->providerConnections()->count() === 0) {
return;
}
$operationRun = $operationRuns->ensureRun(
tenant: $tenant,
type: OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
inputs: [
'tenant_id' => $this->tenantId,
'trigger' => 'health_check',
],
initiator: null,
);
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
);
try {
$result = $generator->generate($tenant, $this->permissionComparison, $operationRun);
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'findings_created' => $result->findingsCreated,
'findings_resolved' => $result->findingsResolved,
'findings_reopened' => $result->findingsReopened,
'findings_unchanged' => $result->findingsUnchanged,
'errors_recorded' => $result->errorsRecorded,
'posture_score' => $result->postureScore,
],
);
} catch (Throwable $e) {
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'permission_posture_check.failed',
'message' => $e->getMessage(),
],
],
);
throw $e;
}
}
}

View File

@ -1,392 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ReviewPackStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
use ZipArchive;
class GenerateReviewPackJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $reviewPackId,
public int $operationRunId,
) {}
public function handle(OperationRunService $operationRunService): void
{
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
$operationRun = OperationRun::query()->find($this->operationRunId);
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
Log::warning('GenerateReviewPackJob: missing records', [
'review_pack_id' => $this->reviewPackId,
'operation_run_id' => $this->operationRunId,
]);
return;
}
$tenant = $reviewPack->tenant;
if (! $tenant instanceof Tenant) {
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found');
return;
}
// Mark running via OperationRunService (auto-sets started_at)
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
try {
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
} catch (Throwable $e) {
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
throw $e;
}
}
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
{
$options = $reviewPack->options ?? [];
$includePii = (bool) ($options['include_pii'] ?? true);
$includeOperations = (bool) ($options['include_operations'] ?? true);
$tenantId = (int) $tenant->getKey();
// 1. Collect StoredReports
$storedReports = StoredReport::query()
->where('tenant_id', $tenantId)
->whereIn('report_type', [
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
])
->get()
->keyBy('report_type');
// 2. Collect open findings
$findings = Finding::query()
->where('tenant_id', $tenantId)
->whereIn('status', Finding::openStatusesForQuery())
->orderBy('severity')
->orderBy('created_at')
->get();
// 3. Collect tenant hardening fields
$hardening = [
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
'rbac_canary_results' => $tenant->rbac_canary_results,
'rbac_last_warnings' => $tenant->rbac_last_warnings,
'rbac_scope_mode' => $tenant->rbac_scope_mode,
];
// 4. Collect recent OperationRuns (30 days)
$recentOperations = $includeOperations
? OperationRun::query()
->where('tenant_id', $tenantId)
->where('created_at', '>=', now()->subDays(30))
->orderByDesc('created_at')
->get()
: collect();
// 5. Data freshness
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
// 6. Build file map
$fileMap = $this->buildFileMap(
storedReports: $storedReports,
findings: $findings,
hardening: $hardening,
recentOperations: $recentOperations,
tenant: $tenant,
dataFreshness: $dataFreshness,
includePii: $includePii,
includeOperations: $includeOperations,
);
// 7. Assemble ZIP
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
try {
$this->assembleZip($tempFile, $fileMap);
// 8. Compute SHA-256
$sha256 = hash_file('sha256', $tempFile);
$fileSize = filesize($tempFile);
// 9. Store on exports disk
$filePath = sprintf(
'review-packs/%s/%s.zip',
$tenant->external_id,
now()->format('Y-m-d-His'),
);
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
// 10. Compute fingerprint
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options);
// 11. Compute summary
$summary = [
'finding_count' => $findings->count(),
'report_count' => $storedReports->count(),
'operation_count' => $recentOperations->count(),
'data_freshness' => $dataFreshness,
];
// 12. Update ReviewPack
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
$reviewPack->update([
'status' => ReviewPackStatus::Ready->value,
'fingerprint' => $fingerprint,
'sha256' => $sha256,
'file_size' => $fileSize,
'file_path' => $filePath,
'file_disk' => 'exports',
'generated_at' => now(),
'expires_at' => now()->addDays($retentionDays),
'summary' => $summary,
]);
// 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification)
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $summary,
);
}
/**
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
* @return array<string, ?string>
*/
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
{
return [
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
];
}
/**
* Build the file map for the ZIP contents.
*
* @return array<string, string>
*/
private function buildFileMap(
$storedReports,
$findings,
array $hardening,
$recentOperations,
Tenant $tenant,
array $dataFreshness,
bool $includePii,
bool $includeOperations,
): array {
$files = [];
// findings.csv
$files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii);
// hardening.json
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
// metadata.json
$files['metadata.json'] = json_encode([
'version' => '1.0',
'tenant_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
'generated_at' => now()->toIso8601String(),
'options' => [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
],
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
// operations.csv
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
// reports/entra_admin_roles.json
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
$files['reports/entra_admin_roles.json'] = json_encode(
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
// reports/permission_posture.json
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$files['reports/permission_posture.json'] = json_encode(
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
// summary.json
$files['summary.json'] = json_encode([
'data_freshness' => $dataFreshness,
'finding_count' => $findings->count(),
'report_count' => $storedReports->count(),
'operation_count' => $recentOperations->count(),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
return $files;
}
/**
* Build findings CSV content.
*
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
*/
private function buildFindingsCsv($findings, bool $includePii): string
{
$handle = fopen('php://temp', 'r+');
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
foreach ($findings as $finding) {
fputcsv($handle, [
$finding->id,
$finding->finding_type,
$finding->severity,
$finding->status,
$includePii ? ($finding->title ?? '') : '[REDACTED]',
$includePii ? ($finding->description ?? '') : '[REDACTED]',
$finding->created_at?->toIso8601String(),
$finding->updated_at?->toIso8601String(),
]);
}
rewind($handle);
$content = stream_get_contents($handle);
fclose($handle);
return $content;
}
/**
* Build operations CSV content.
*/
private function buildOperationsCsv($operations, bool $includePii): string
{
$handle = fopen('php://temp', 'r+');
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
foreach ($operations as $operation) {
fputcsv($handle, [
$operation->id,
$operation->type,
$operation->status,
$operation->outcome,
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
$operation->started_at?->toIso8601String(),
$operation->completed_at?->toIso8601String(),
]);
}
rewind($handle);
$content = stream_get_contents($handle);
fclose($handle);
return $content;
}
/**
* Redact PII from a report payload.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function redactReportPayload(array $payload, bool $includePii): array
{
if ($includePii) {
return $payload;
}
return $this->redactArrayPii($payload);
}
/**
* Recursively redact PII fields from an array.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function redactArrayPii(array $data): array
{
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
foreach ($data as $key => $value) {
if (is_string($key) && in_array($key, $piiKeys, true)) {
$data[$key] = '[REDACTED]';
} elseif (is_array($value)) {
$data[$key] = $this->redactArrayPii($value);
}
}
return $data;
}
/**
* Assemble a ZIP file from a file map.
*
* @param array<string, string> $fileMap
*/
private function assembleZip(string $tempFile, array $fileMap): void
{
$zip = new ZipArchive;
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
throw new \RuntimeException("Failed to create ZIP archive: error code {$result}");
}
// Add files in alphabetical order for deterministic output
ksort($fileMap);
foreach ($fileMap as $filename => $content) {
$zip->addFromString($filename, $content);
}
$zip->close();
}
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
{
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)],
],
);
}
}

View File

@ -16,8 +16,8 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter; use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -219,14 +219,6 @@ public function handle(
], ],
); );
// Dispatch posture finding generation when permission comparison is available
if (($permissionComparison['overall_status'] ?? null) !== 'error') {
GeneratePermissionPostureFindingsJob::dispatch(
(int) $tenant->getKey(),
$permissionComparison,
);
}
if ($result->healthy) { if ($result->healthy) {
$run = $runs->updateRun( $run = $runs->updateRun(
$this->operationRun, $this->operationRun,

View File

@ -1,86 +0,0 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\RbacHealthService;
use App\Services\OperationRunService;
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;
use RuntimeException;
class RefreshTenantRbacHealthJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
RbacHealthService $rbacHealthService,
OperationRunService $runs,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$result = $rbacHealthService->check($tenant);
if (! $this->operationRun instanceof OperationRun) {
return;
}
$status = $result['status'] ?? 'error';
$isHealthy = in_array($status, ['ok', 'configured', 'manual_assignment_required'], true);
if ($isHealthy) {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'rbac.health_check.failed',
'reason_code' => $result['reason'] ?? 'unknown',
'message' => sprintf('RBAC health check completed with status: %s', $status),
]],
);
}
}

View File

@ -10,7 +10,9 @@
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -178,6 +180,14 @@ public function handle(
); );
} }
$this->notifyCompleted(
initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null,
removed: $removed,
requested: $requestedCount,
missing: count($missingIds),
outcome: $outcome,
);
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
$auditLogger->log( $auditLogger->log(
@ -202,7 +212,100 @@ public function handle(
$opService->failRun($this->operationRun, $throwable); $opService->failRun($this->operationRun, $throwable);
} }
$this->notifyFailed(
initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null,
reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
);
throw $throwable; throw $throwable;
} }
} }
private function notifyCompleted(
?User $initiator,
?Tenant $tenant,
int $removed,
int $requested,
int $missing,
?string $outcome,
): void {
if (! $initiator instanceof User) {
return;
}
if (! $this->operationRun) {
return;
}
$message = "Removed {$removed} policies";
if ($missing > 0) {
$message .= " ({$missing} missing)";
}
if ($requested !== $removed && $missing === 0) {
$skipped = max(0, $requested - $removed);
if ($skipped > 0) {
$message .= " ({$skipped} not removed)";
}
}
$message .= '.';
$partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0;
$failed = in_array((string) $outcome, ['failed'], true);
$notification = Notification::make()
->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed'))
->body($message);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
if ($failed) {
$notification->danger();
} elseif ($partial) {
$notification->warning();
} else {
$notification->success();
}
$notification
->sendToDatabase($initiator)
->send();
}
private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void
{
if (! $initiator instanceof User) {
return;
}
if (! $this->operationRun) {
return;
}
$notification = Notification::make()
->title('Removal failed')
->body($reason);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
$notification
->danger()
->sendToDatabase($initiator)
->send();
}
} }

View File

@ -2,8 +2,6 @@
namespace App\Jobs; namespace App\Jobs;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -214,33 +212,6 @@ public function handle(
throw new RuntimeException('OperationRun is required for RestoreAssignmentsJob execution.'); throw new RuntimeException('OperationRun is required for RestoreAssignmentsJob execution.');
} }
try {
app(WriteGateInterface::class)->evaluate($tenant, 'assignments.restore');
} catch (ProviderAccessHardeningRequired $e) {
$operationRunService->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'hardening.write_blocked',
'reason_code' => $e->reasonCode,
'message' => $e->reasonMessage,
]],
summaryCounts: [
'total' => max(1, count($this->assignments)),
'processed' => 0,
'success' => 0,
'failed' => max(1, count($this->assignments)),
'skipped' => 0,
],
);
return [
'outcomes' => [],
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
];
}
$executionIdentityKey = AssignmentJobFingerprint::executionIdentityKey( $executionIdentityKey = AssignmentJobFingerprint::executionIdentityKey(
jobType: self::OPERATION_TYPE, jobType: self::OPERATION_TYPE,
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),

View File

@ -6,6 +6,7 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
@ -13,8 +14,11 @@
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -158,6 +162,13 @@ public function handle(
], ],
); );
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: self::STATUS_SKIPPED,
errorMessage: 'Schedule is archived; run will not execute.',
);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_skipped', action: 'backup_schedule.run_skipped',
@ -206,6 +217,13 @@ public function handle(
], ],
); );
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: self::STATUS_SKIPPED,
errorMessage: 'Another run is already in progress for this schedule.',
);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_skipped', action: 'backup_schedule.run_skipped',
@ -227,6 +245,8 @@ public function handle(
try { try {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
$this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_started', action: 'backup_schedule.run_started',
@ -271,6 +291,13 @@ public function handle(
], ],
); );
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: self::STATUS_SKIPPED,
errorMessage: 'All configured policy types are unknown.',
);
return; return;
} }
@ -394,6 +421,13 @@ public function handle(
nowUtc: $nowUtc, nowUtc: $nowUtc,
); );
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: $status,
errorMessage: $errorMessage,
);
if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) { if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey())); Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
} }
@ -451,6 +485,13 @@ public function handle(
], ],
); );
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: self::STATUS_FAILED,
errorMessage: (string) $mapped['error_message'],
);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_failed', action: 'backup_schedule.run_failed',
@ -481,6 +522,80 @@ private function resolveBackupScheduleId(): int
return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0; return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0;
} }
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
{
$userId = $this->operationRun?->user_id;
if (! $userId) {
return;
}
$user = User::query()->find($userId);
if (! $user instanceof User) {
return;
}
Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
])
->sendToDatabase($user);
}
private function notifyScheduleRunFinished(
Tenant $tenant,
BackupSchedule $schedule,
string $status,
?string $errorMessage,
): void {
$userId = $this->operationRun?->user_id;
if (! $userId) {
return;
}
$user = User::query()->find($userId);
if (! $user instanceof User) {
return;
}
$title = match ($status) {
self::STATUS_SUCCESS => 'Backup completed',
self::STATUS_PARTIAL => 'Backup completed (partial)',
self::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
$notification = Notification::make()
->title($title)
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status));
if (is_string($errorMessage) && $errorMessage !== '') {
$notification->body($notification->getBody()."\n".$errorMessage);
}
match ($status) {
self::STATUS_SUCCESS => $notification->success(),
self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
$notification
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
])
->sendToDatabase($user);
}
private function finishSchedule( private function finishSchedule(
BackupSchedule $schedule, BackupSchedule $schedule,
string $status, string $status,

View File

@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ScanEntraAdminRolesJob 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(
EntraAdminRolesReportService $reportService,
EntraAdminRolesFindingGenerator $findingGenerator,
OperationRunService $operationRuns,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
return;
}
// FR-018: Skip tenants without active provider connection
$hasConnection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->where('status', 'connected')
->exists();
if (! $hasConnection) {
return;
}
$initiator = $this->initiatorUserId !== null
? User::query()->find($this->initiatorUserId)
: null;
$operationRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'entra.admin_roles.scan',
identityInputs: [
'tenant_id' => $this->tenantId,
'trigger' => 'scan',
],
context: [
'workspace_id' => $this->workspaceId,
'initiator_user_id' => $this->initiatorUserId,
],
initiator: $initiator instanceof User ? $initiator : null,
);
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
);
try {
$reportResult = $reportService->generate($tenant, $operationRun);
$findingResult = $findingGenerator->generate($tenant, $reportResult->payload, $operationRun);
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'report_created' => $reportResult->created ? 1 : 0,
'report_deduped' => $reportResult->created ? 0 : 1,
'findings_created' => $findingResult->created,
'findings_resolved' => $findingResult->resolved,
'findings_reopened' => $findingResult->reopened,
'findings_unchanged' => $findingResult->unchanged,
'alert_events_produced' => $findingResult->alertEventsProduced,
],
);
} 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' => 'entra.admin_roles.scan.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : 'Entra admin roles scan failed.',
]],
);
throw $e;
}
}
}

View File

@ -320,12 +320,15 @@ public function table(Table $table): Table
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->info()
->send(); ->send();
return; return;

View File

@ -20,10 +20,6 @@ class AlertRule extends Model
public const string EVENT_SLA_DUE = 'sla_due'; public const string EVENT_SLA_DUE = 'sla_due';
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
public const string TENANT_SCOPE_ALL = 'all'; public const string TENANT_SCOPE_ALL = 'all';
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist'; public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';

View File

@ -16,10 +16,6 @@ class Finding extends Model
public const string FINDING_TYPE_DRIFT = 'drift'; public const string FINDING_TYPE_DRIFT = 'drift';
public const string FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
public const string FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
public const string SEVERITY_LOW = 'low'; public const string SEVERITY_LOW = 'low';
public const string SEVERITY_MEDIUM = 'medium'; public const string SEVERITY_MEDIUM = 'medium';
@ -32,33 +28,11 @@ class Finding extends Model
public const string STATUS_ACKNOWLEDGED = 'acknowledged'; public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_REOPENED = 'reopened';
public const string STATUS_RESOLVED = 'resolved';
public const string STATUS_CLOSED = 'closed';
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'acknowledged_at' => 'datetime', 'acknowledged_at' => 'datetime',
'closed_at' => 'datetime',
'due_at' => 'datetime',
'evidence_jsonb' => 'array', 'evidence_jsonb' => 'array',
'first_seen_at' => 'datetime',
'in_progress_at' => 'datetime',
'last_seen_at' => 'datetime',
'reopened_at' => 'datetime',
'resolved_at' => 'datetime',
'sla_days' => 'integer',
'times_seen' => 'integer',
'triaged_at' => 'datetime',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
@ -81,83 +55,6 @@ public function acknowledgedByUser(): BelongsTo
return $this->belongsTo(User::class, 'acknowledged_by_user_id'); return $this->belongsTo(User::class, 'acknowledged_by_user_id');
} }
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function assigneeUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_user_id');
}
public function closedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'closed_by_user_id');
}
/**
* @return array<int, string>
*/
public static function openStatuses(): array
{
return [
self::STATUS_NEW,
self::STATUS_TRIAGED,
self::STATUS_IN_PROGRESS,
self::STATUS_REOPENED,
];
}
/**
* @return array<int, string>
*/
public static function terminalStatuses(): array
{
return [
self::STATUS_RESOLVED,
self::STATUS_CLOSED,
self::STATUS_RISK_ACCEPTED,
];
}
/**
* @return array<int, string>
*/
public static function openStatusesForQuery(): array
{
return [
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
}
public static function canonicalizeStatus(?string $status): ?string
{
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status;
}
public static function isOpenStatus(?string $status): bool
{
return is_string($status) && in_array($status, self::openStatusesForQuery(), true);
}
public static function isTerminalStatus(?string $status): bool
{
$canonical = self::canonicalizeStatus($status);
return is_string($canonical) && in_array($canonical, self::terminalStatuses(), true);
}
public function hasOpenStatus(): bool
{
return self::isOpenStatus($this->status);
}
public function acknowledge(User $user): void public function acknowledge(User $user): void
{ {
if ($this->status === self::STATUS_ACKNOWLEDGED) { if ($this->status === self::STATUS_ACKNOWLEDGED) {
@ -172,27 +69,4 @@ public function acknowledge(User $user): void
$this->save(); $this->save();
} }
/**
* Auto-resolve the finding.
*/
public function resolve(string $reason): void
{
$this->status = self::STATUS_RESOLVED;
$this->resolved_at = now();
$this->resolved_reason = $reason;
$this->save();
}
/**
* Re-open a resolved finding.
*/
public function reopen(array $evidence): void
{
$this->status = self::STATUS_NEW;
$this->resolved_at = null;
$this->resolved_reason = null;
$this->evidence_jsonb = $evidence;
$this->save();
}
} }

View File

@ -1,133 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\ReviewPackStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReviewPack extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string STATUS_QUEUED = 'queued';
public const string STATUS_GENERATING = 'generating';
public const string STATUS_READY = 'ready';
public const string STATUS_FAILED = 'failed';
public const string STATUS_EXPIRED = 'expired';
protected $guarded = [];
protected function casts(): array
{
return [
'summary' => 'array',
'options' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
'file_size' => 'integer',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<OperationRun, $this>
*/
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeReady(Builder $query): Builder
{
return $query->where('status', self::STATUS_READY);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeExpired(Builder $query): Builder
{
return $query->where('status', self::STATUS_EXPIRED);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopePastRetention(Builder $query): Builder
{
return $query->where('expires_at', '<', now());
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForTenant(Builder $query, int $tenantId): Builder
{
return $query->where('tenant_id', $tenantId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeLatestReady(Builder $query): Builder
{
return $query->ready()->latest('generated_at');
}
public function isReady(): bool
{
return $this->status === self::STATUS_READY;
}
public function isExpired(): bool
{
return $this->status === self::STATUS_EXPIRED;
}
public function getStatusEnum(): ReviewPackStatus
{
return ReviewPackStatus::from($this->status);
}
}

View File

@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoredReport extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string REPORT_TYPE_PERMISSION_POSTURE = 'permission_posture';
public const string REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';
protected $fillable = [
'workspace_id',
'tenant_id',
'report_type',
'payload',
'fingerprint',
'previous_fingerprint',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'payload' => 'array',
];
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

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