Compare commits

...

37 Commits

Author SHA1 Message Date
200498fa8e feat(113): Platform Ops Runbooks — UX Polish (Filament-native, system theme, live scope) (#137)
## Summary

Implements and polishes the Platform Ops Runbooks feature (Spec 113) — the operator control plane for safe backfills and data repair from `/system`.

## Changes

### UX Polish (Phase 7 — US4)
- **Filament-native components**: Rewrote `runbooks.blade.php` and `view-run.blade.php` using `<x-filament::section>` instead of raw Tailwind div cards. Cards now render correctly with Filament's built-in borders, shadows and dark mode.
- **System panel theme**: Created `resources/css/filament/system/theme.css` and registered `->viteTheme()` on `SystemPanelProvider`. The system panel previously had no theme CSS registered — Tailwind utility classes weren't compiled for its views, causing the warning icon SVG to expand to full container size.
- **Live scope selector**: Added `->live()` to the scope `Radio` field so "Single tenant" immediately reveals the tenant search dropdown without requiring a Submit first.

### Core Feature (Phases 1–6, previously shipped)
- `/system/ops/runbooks` — runbook catalog, preflight, run with typed confirmation + reason
- `/system/ops/runs` — run history table with status/outcome badges
- `/system/ops/runs/{id}` — run detail view with summary counts, failures, collapsible context
- `FindingsLifecycleBackfillRunbookService` — preflight + execution logic
- AllowedTenantUniverse — scopes tenant picker to non-platform tenants only
- RBAC: `platform.ops.view`, `platform.runbooks.view`, `platform.runbooks.run`, `platform.runbooks.findings.lifecycle_backfill`
- Rate-limited `/system/login` (10/min per IP+username)
- Distinct session cookie for `/system` isolation

## Test Coverage
- 16 tests / 141 assertions — all passing
- Covers: page access, RBAC, preflight, run dispatch, scope selector, run detail, run list

## Checklist
- [x] Filament v5 / Livewire v4 compliant
- [x] Provider registered in `bootstrap/providers.php`
- [x] Destructive actions require confirmation (`->requiresConfirmation()`)
- [x] System panel theme registered (`viteTheme`)
- [x] Pint clean
- [x] Tests pass

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #137
2026-02-27 01:11:25 +00:00
32c3a64147 feat(112): LIST $expand parity + Entra principal names (#136)
Implements LIST `$expand` parity with GET by forwarding caller-provided, contract-allowlisted expands.

Key changes:
- Entra Admin Roles scan now requests `expand=principal` for role assignments so `principal.displayName` can render.
- `$expand` normalization/sanitization: top-level comma split (commas inside balanced parentheses preserved), trim, dedupe, allowlist exact match, caps (max 10 tokens, max 200 chars/token).
- Diagnostics when expands are removed/truncated (non-prod warning, production low-noise).

Tests:
- Adds/extends unit coverage for Graph contract sanitization, list request shaping, and the EntraAdminRolesReportService.

Spec artifacts included under `specs/112-list-expand-parity/`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #136
2026-02-25 23:54:20 +00:00
7ac53f4cc4 feat(111): findings workflow + SLA settings (#135)
Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

Key changes:
- Findings workflow service + SLA policy and alerting.
- Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults.
- New migrations, jobs, command, UI/resource updates, and comprehensive test coverage.

Tests:
- `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
2026-02-25 01:48:01 +00:00
f13a4ce409 feat(110): Ops-UX enterprise start/dedup standard (repo-wide) (#134)
Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces.

Key points
- Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running).
- Dedup paths: canonical “already queued” toast.
- Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately.
- Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract).

Tests & formatting
- Full suite: 1738 passed, 8 skipped (8477 assertions).
- Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass).

Notable change
- Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #134
2026-02-24 09:30:15 +00:00
9f5c99317b Fix Review Pack generation UX + notifications (#133)
## Summary
- Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe).
- Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes.
- Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification.

## Key Changes
- Review Pack generate action now:
  - Shows queued toast only when a new pack is actually created/queued.
  - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php`

## Notes
- No global search behavior changes for ReviewPacks (still excluded).
- Destructive actions remain confirmation-gated (`->requiresConfirmation()`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #133
2026-02-23 19:42:52 +00:00
0dc79520a4 feat: provider access hardening (RBAC write gate) (#132)
Implements provider access hardening for Intune write operations:

- RBAC-based write gate with configurable staleness thresholds
- Gate enforced at restore start and in jobs (execute + assignments)
- UI affordances: disabled rerun action, tenant RBAC status card, refresh RBAC action
- Audit logging for blocked writes
- Ops UX label: `rbac.health_check` now displays as “RBAC health check”
- Adds/updates Pest tests and SpecKit artifacts for feature 108

Notes:
- Filament v5 / Livewire v4 compliant.
- Destructive actions require confirmation.
- Assets: no new global assets.

Tested:
- `vendor/bin/sail artisan test --compact` (suite previously green) + focused OpsUx tests for OperationCatalog labels.
- `vendor/bin/sail bin pint --dirty`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #132
2026-02-23 00:49:37 +00:00
e15eee8f26 fix: consolidate tenant creation + harden selection flows (#131)
## Summary
- Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard.
- Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes.
- Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs).

## Key changes
- Tenant creation
  - Removed `CreateTenant` page + route from `TenantResource`.
  - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled).
  - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`).
- Onboarding wizard
  - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth).
  - Disabled topbar on the onboarding page to avoid lazy-loaded notifications.
- Choose tenant
  - Enterprise UI redesign + workspace context.
  - Uses Livewire `selectTenant()` instead of a form POST.
  - Disabled topbar and gated BODY_END hook to avoid background polling.
- Baseline profiles
  - Hide header create action when table is empty to avoid duplicate CTAs.

## Tests
- `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'`
- `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'`
- `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'`
- `vendor/bin/sail artisan test --compact --filter='BaselineProfile'`
- `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'`

## Notes
- Filament v5 / Livewire v4 compatible.
- No new assets introduced; no deploy pipeline changes required.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #131
2026-02-22 19:54:24 +00:00
8bee824966 fix: restore full suite (Pest helper collision + ops-ux spec sync) (#130)
## Why
`dev` full suite was hard-failing with `PHP Fatal error: Cannot redeclare function makeAssignment()` due to two Pest files defining the same global helper.

Additionally, Ops-UX tests were out of sync with the new summary rendering + new whitelisted keys.

## What changed
- Renamed the Entra Admin Roles test helper to `makeEntraAssignment()` to avoid global collision.
- Updated Ops-UX canonical key list in `specs/055-ops-ux-rollout/spec.md` to include:
  - `report_created`, `report_deduped`, `alert_events_produced`
- Updated `SummaryCountsWhitelistTest` to match the new summary rendering:
  - no `Summary:` prefix
  - humanized keys (`Total`, `Processed`)

## Verification
- `vendor/bin/sail artisan test --compact`:
  - **1572 passed**, **7 skipped** (8044 assertions)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #130
2026-02-22 11:36:27 +00:00
33a2b1a242 feat(106): Required Permissions sidebar stays on workspace nav (#129)
## Summary

Fixes the sidebar context bug where navigating to the **Required Permissions** page (`/admin/tenants/{id}/required-permissions`) would switch the sidebar from workspace navigation to tenant-scoped navigation, confusing users.

## Problem

The `EnsureFilamentTenantSelected` middleware detected a tenant ID in the URL and called `setTenant()`, which switched the entire sidebar to tenant-scoped navigation. The Required Permissions page is logically a **workspace-level** page that happens to reference a tenant — it should keep showing workspace nav.

## Changes

### Middleware (`EnsureFilamentTenantSelected.php`)
- **`isWorkspaceScopedPageWithTenant()`** — new private helper that detects workspace-scoped pages containing a tenant parameter via regex
- **Livewire referer bypass** — checks if a Livewire request originates from a workspace-scoped page and preserves workspace nav
- **`setTenant()` bypass** — skips tenant activation and `rememberLastTenantId()` for workspace-scoped pages

### Tests
- **`RequiredPermissionsSidebarTest.php`** (NEW) — 7 tests covering:
  - Workspace nav visible on required-permissions page
  - Tenant nav absent on required-permissions page
  - Direct URL access preserves workspace nav
  - 404 for non-member tenants
  - 404 for tenants without entitlement
  - Tenant pages still show tenant sidebar (regression guard)
  - Scoped tenant resolves correctly on tenant pages

### Pre-existing test fixes
- **`RequiredPermissionsEmptyStateTest`** — fixed URL assertion (dynamic `TenantResource::getUrl()` instead of hardcoded `/admin/onboarding`)
- **`RequiredPermissionsLinksTest`** — fixed URL assertion + multiline HTML `data-testid` assertion
- **`RequiredPermissionsFiltersTest`** — fixed `entra_permissions` config leak from branch 105

## Test Results

| Suite | Result |
|-------|--------|
| RequiredPermissions (26 tests) | **26 pass** (73 assertions) |
| Full regression (1571 tests) | **1562 pass**, 2 fail (pre-existing OpsUx), 7 skipped |

The 2 failures are pre-existing in `OpsUx/OperationCatalogCoverageTest` and `OpsUx/OperationSummaryKeysSpecTest` — unrelated to this feature.

## Spec Artifacts

- `specs/106-required-permissions-sidebar-context/plan.md`
- `specs/106-required-permissions-sidebar-context/tasks.md`
- `specs/106-required-permissions-sidebar-context/research.md`
- `specs/106-required-permissions-sidebar-context/data-model.md`
- `specs/106-required-permissions-sidebar-context/quickstart.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #129
2026-02-22 02:42:44 +00:00
6a15fe978a feat: Spec 105 — Entra Admin Roles Evidence + Findings (#128)
## Summary

Automated scanning of Entra ID directory roles to surface high-privilege role assignments as trackable findings with alerting support.

## What's included

### Core Services
- **EntraAdminRolesReportService** — Fetches role definitions + assignments via Graph API, builds payload with fingerprint deduplication
- **EntraAdminRolesFindingGenerator** — Creates/resolves/reopens findings based on high-privilege role catalog
- **HighPrivilegeRoleCatalog** — Curated list of high-privilege Entra roles (Global Admin, Privileged Auth Admin, etc.)
- **ScanEntraAdminRolesJob** — Queued job orchestrating scan → report → findings → alerts pipeline

### UI
- **AdminRolesSummaryWidget** — Tenant dashboard card showing last scan time, high-privilege assignment count, scan trigger button
- RBAC-gated: `ENTRA_ROLES_VIEW` for viewing, `ENTRA_ROLES_MANAGE` for scan trigger

### Infrastructure
- Graph contracts for `entraRoleDefinitions` + `entraRoleAssignments`
- `config/entra_permissions.php` — Entra permission registry
- `StoredReport.fingerprint` migration (deduplication support)
- `OperationCatalog` label + duration for `entra.admin_roles.scan`
- Artisan command `entra:scan-admin-roles` for CLI/scheduled use

### Global UX improvement
- **SummaryCountsNormalizer**: Zero values filtered, snake_case keys humanized (e.g. `report_deduped: 1` → `Report deduped: 1`). Affects all operation notifications.

## Test Coverage
- **12 test files**, **79+ tests**, **307+ assertions**
- Report service, finding generator, job orchestration, widget rendering, alert integration, RBAC enforcement, badge mapping

## Spec artifacts
- `specs/105-entra-admin-roles-evidence-findings/tasks.md` — Full task breakdown (38 tasks, all complete)
- `specs/105-entra-admin-roles-evidence-findings/checklists/requirements.md` — All items checked

## Files changed
46 files changed, 3641 insertions(+), 15 deletions(-)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #128
2026-02-22 02:37:36 +00:00
ef380b67d1 feat(104): Provider Permission Posture (#127)
Implements Spec 104: Provider Permission Posture.

What changed
- Generates permission posture findings after each tenant permission compare (queued)
- Stores immutable posture snapshots as StoredReports (JSONB payload)
- Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()`
- Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules
- Adds retention pruning command + daily schedule for StoredReports
- Adds badge mappings for `resolved` finding status and `permission_posture` finding type

UX fixes discovered during manual verification
- Hide “Diff” section for non-drift findings (only drift findings show diff)
- Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding)
- Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state)

Verification
- Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests)
- Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests)
- Ran `vendor/bin/sail bin pint --dirty`

Filament v5 / Livewire v4 compliance
- Filament v5 + Livewire v4: no Livewire v3 usage.

Panel provider registration (Laravel 11+)
- No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`.

Global search rule
- No changes to global-searchable resources.

Destructive actions
- No new destructive Filament actions were added in this PR.

Assets / deploy notes
- No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged.

Test coverage
- New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #127
2026-02-21 22:32:52 +00:00
d32b2115a8 Spec 103: IA semantics (scope vs filter vs targeting) + UI polish (#126)
Implements Spec 103 (IA semantics: Scope vs Filter vs Targeting) across Monitoring + Manage.

Changes
- Monitoring tenant indicator copy: “All tenants” / “Filtered by tenant: …”
- Alerts KPI header resolves tenant via OperateHubShell::activeEntitledTenant() for consistency
- Manage list pages (Alert Rules / Destinations) no longer show tenant indicator
- AlertRule form uses targeting semantics + sections (Rule / Applies to / Delivery)
- Additional UI polish: resource sections, tenant view widgets layout, RBAC progressive disclosure (“Not configured” when empty)

Notes
- US6 (“Add current tenant” convenience button) intentionally skipped (optional P3).

Testing
- CI=1 vendor/bin/sail artisan test tests/Feature/TenantRBAC/ tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #126
2026-02-21 00:28:15 +00:00
558b5d3807 Fix tenant dashboard 500 (missing BaselineCompareRun) (#125)
## Summary
Fixes a tenant dashboard Internal Server Error caused by `App\\Models\\BaselineCompareRun` being referenced but not existing.

## What changed
- Dashboard widget now uses `OperationRun` (`type=baseline_compare`, context baseline_profile_id, ordered by completed_at) instead of the missing model.
- Added regression test ensuring tenant dashboard renders when a baseline assignment exists.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php`

## Notes
No UX changes; this is a runtime stability fix only.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #125
2026-02-20 21:07:34 +00:00
8f8bc24d1d feat: upgrade Filament to v5.2.1 (#124)
What: Upgrade Filament auf v5.2.1 (inkl. composer.lock + veröffentlichte Filament assets unter public), SpecKit-Doku unter specs/102-..., plus kleine Anpassungen in Tests + Tenant-Scoping in BackupSetResource.
Verification: vendor/bin/sail bin pint --dirty --format agent (pass) + vendor/bin/sail artisan test --compact [AlertDeliveryViewerTest.php](http://_vscodecontentref_/6) tests/Feature/Filament/BackupSetGraphSafetyTest.php (pass)
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #124
2026-02-20 12:20:42 +00:00
a30be84084 Baseline governance UX polish + view Infolist (#123)
Summary:
- Baseline Compare landing: enterprise UI (stats grid, critical drift banner, better actions), navigation grouping under Governance, and Action Surface Contract declaration.
- Baseline Profile view page: switches from disabled form fields to proper Infolist entries for a clean read-only view.
- Fixes tenant name column usages (`display_name` → `name`) in baseline assignment flows.
- Dashboard: improved baseline governance widget with severity breakdown + last compared.

Notes:
- Filament v5 / Livewire v4 compatible.
- Destructive actions remain confirmed (`->requiresConfirmation()`).

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #123
2026-02-19 23:56:09 +00:00
d49d33ac27 feat(alerts): test message + last test status + deep links (#122)
Implements feature 100 (Alert Targets):

- US1: “Send test message” action (RBAC + confirmation + rate limit + audit + async job)
- US2: Derived “Last test” status badge (Never/Sent/Failed/Pending) on view + edit surfaces
- US3: “View last delivery” deep link + deliveries viewer filters (event_type, destination) incl. tenantless test deliveries

Tests:
- Full suite green (1348 passed, 7 skipped)
- Added focused feature tests for send test, last test resolver/badges, and deep-link filters

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #122
2026-02-18 23:12:38 +00:00
3ed275cef3 feat(alerts): Monitoring cluster + v1 resources (spec 099) (#121)
Implements spec `099-alerts-v1-teams-email`.

- Monitoring navigation: Alerts as a cluster under Monitoring; default landing is Alert deliveries.
- Tenant panel: Alerts points to `/admin/alerts` and the cluster navigation is hidden in tenant panel.
- Guard compliance: removes direct `Gate::` usage from Alert resources so `NoAdHocFilamentAuthPatternsTest` passes.

Verification:
- Full suite: `1348 passed, 7 skipped` (EXIT=0).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #121
2026-02-18 15:20:43 +00:00
c57f680f39 feat: Workspace settings slices v1 (backup, drift, operations) (#120)
Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
2026-02-16 03:18:33 +00:00
e241e27853 Settings foundation: workspace controls (#119)
Implements the Settings foundation workspace controls.

Includes:
- Settings foundation UI/controls scoped to workspace context
- Related onboarding/consent flow adjustments as included in branch history

Testing:
- `vendor/bin/sail artisan test --compact --no-ansi --filter=SettingsFoundation`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #119
2026-02-16 01:11:24 +00:00
521fb6baaf Fix tenant permission inserts missing workspace_id (#118)
Fixes a production/queue-worker failure where `tenant_permissions.workspace_id` could be omitted from INSERTs when persisting permission check results.

Changes:
- Ensure `workspace_id` is included in the `updateOrCreate()` attributes array (lookup + create merge).
- Add a regression test covering a tenant instance without `workspace_id` loaded.

Notes:
- Queue workers should be restarted (`queue:restart`) after deploy so they pick up the new code.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #118
2026-02-16 00:58:34 +00:00
ef5c223172 fix(onboarding): preserve workspace scope and consent flow (#117)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #117
2026-02-15 22:27:55 +00:00
9d0c884251 fix: prevent null workspace_id in tenant_permissions (#116)
Adds workspace_id to the TenantPermission::updateOrCreate(...) payload and gates persistence when $tenant->workspace_id is null: TenantPermissionService.php
Updates/extends tests so this is covered:
Persists with workspace even if events are disabled
Does not persist at all when tenant workspace is missing
TenantPermissionServiceTest.php

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #116
2026-02-15 21:56:37 +00:00
03127a670b Spec 096: Ops polish (assignment summaries + dedupe + reconcile tracking + seed DX) (#115)
Implements Spec 096 ops polish bundle:

- Persist durable OperationRun.summary_counts for assignment fetch/restore (final attempt wins)
- Server-side dedupe for assignment jobs (15-minute cooldown + non-canonical skip)
- Track ReconcileAdapterRunsJob via workspace-scoped OperationRun + stable failure codes + overlap prevention
- Seed DX: ensure seeded tenants use UUID v4 external_id and seed satisfies workspace_id NOT NULL constraints

Verification (local / evidence-based):
- `vendor/bin/sail artisan test --compact tests/Feature/Operations/AssignmentRunSummaryCountsTest.php tests/Feature/Operations/AssignmentJobDedupeTest.php tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php tests/Feature/Seed/PoliciesSeederExternalIdTest.php`
- `vendor/bin/sail bin pint --dirty`

Spec artifacts included under `specs/096-ops-polish-assignment-dedupe-system-tracking/` (spec/plan/tasks/checklists).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #115
2026-02-15 20:49:38 +00:00
eec93b510a Spec 095: Graph contracts registry completeness + registry-backed call sites (#114)
Implements Spec 095.

What changed
- Registers 4 Graph resources in the contract registry (plus required subresource template)
- Refactors in-scope call sites to resolve Graph paths via the registry (no ad-hoc endpoints for these resources)
- Adds/updates regression tests to prevent future drift (missing registry entries and endpoint string reintroduction)
- Includes full SpecKit artifacts under specs/095-graph-contracts-registry-completeness/

Validation
- Focused tests:
  - `vendor/bin/sail artisan test --compact tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php tests/Feature/SettingsCatalogDefinitionResolverTest.php`

Notes
- Livewire v4.0+ / Filament v5 compliant (no UI changes).
- No new routes/pages; no RBAC model changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #114
2026-02-15 15:02:27 +00:00
bda1d90fc4 Spec 094: Assignment ops observability hardening (#113)
Implements spec 094 (assignment fetch/restore observability hardening):

- Adds OperationRun tracking for assignment fetch (during backup) and assignment restore (during restore execution)
- Normalizes failure codes/reason_code and sanitizes failure messages
- Ensures exactly one audit log entry per assignment restore execution
- Enforces correct guard/membership vs capability semantics on affected admin surfaces
- Switches assignment Graph services to depend on GraphClientInterface

Also includes Postgres-only FK defense-in-depth check and a discoverable `composer test:pgsql` runner (scoped to the FK constraint test).

Tests:
- `vendor/bin/sail artisan test --compact` (passed)
- `vendor/bin/sail composer test:pgsql` (passed)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #113
2026-02-15 14:08:14 +00:00
92a36ab89e SCOPE-001: DB-level workspace isolation via workspace_id (#112)
Implements Spec 093 (SCOPE-001) workspace isolation at the data layer.

What changed
- Adds `workspace_id` to 12 tenant-owned tables and enforces correct binding.
- Model write-path enforcement derives workspace from tenant + rejects mismatches.
- Prevents `tenant_id` changes (immutability) on tenant-owned records.
- Adds queued backfill command + job (`tenantpilot:backfill-workspace-ids`) with OperationRun + AuditLog observability.
- Enforces DB constraints (NOT NULL + FK `workspace_id` → `workspaces.id` + composite FK `(tenant_id, workspace_id)` → `tenants(id, workspace_id)`), plus audit_logs invariant.

UI / operator visibility
- Monitor backfill runs in **Monitoring → Operations** (OperationRun).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/WorkspaceIsolation`

Notes
- Backfill is queued: ensure a queue worker is running (`vendor/bin/sail artisan queue:work`).

Spec package
- `specs/093-scope-001-workspace-id-isolation/` (plan, tasks, contracts, quickstart, research)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #112
2026-02-14 22:34:02 +00:00
3ddf8c3fd6 Constitution v1.8.2: scope ownership + spec scope fields (#111)
Amends the constitution to clarify admin vs tenant-context vs workspace-context, and codifies data ownership boundaries.

Changes:
- RBAC Context plane clarification:
  - Tenant/Admin plane: `/admin`
  - Tenant-context routes: `/admin/t/{tenant}/...` (tenant-scoped)
  - Workspace-context canonical routes under `/admin/...` must still enforce entitlement before revealing tenant-owned records
- Adds SCOPE-001 (Scope & Ownership Clarification) under Tenant Isolation.
- Extends RBAC-UX-007 with workspace-context Global Search rules.
- Adds SCOPE-002 (Spec Scope Fields) to Filament Action Surface Contract DoD gates.
- Version bump: 1.8.2, Last Amended: 2026-02-14.

Propagation:
- Updates SpecKit templates to include Spec Scope Fields and updated RBAC plane wording.

Files:
- `.specify/memory/constitution.md`
- `.specify/templates/spec-template.md`
- `.specify/templates/plan-template.md`
- `.specify/templates/tasks-template.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #111
2026-02-14 19:40:29 +00:00
5770c7b76b Spec 092: Legacy Purge (runs/routes/UI/test shims) (#110)
Implements Spec 092 legacy purge.

Key changes:
- Remove legacy Inventory landing page + view; link Inventory entry directly to Inventory Items.
- Update Drift landing copy to "operation runs"; remove URL heuristic from context bar.
- Remove legacy redirect shim route and assert 404 for old bookmarks.
- Staged job payload change: remove legacy ctor arg; keep legacy field for deserialization compatibility; new payload omits field.
- Remove legacy notification artifact.
- Remove legacy test shim + update tests; strengthen guard suite with scoped exception for job compat field.
- Add spec/plan/tasks/checklist artifacts under specs/092-legacy-purge-final.

Tests:
- Focused Pest suite for guards, legacy routes, redirect behavior, job compatibility, drift copy.
- Pint run: `vendor/bin/sail bin pint --dirty`.

Notes:
- Deploy B final removal of `backupScheduleRunId` should occur only after the compatibility window defined in the spec.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #110
2026-02-14 18:43:56 +00:00
1c098441aa feat(spec-091): BackupSchedule lifecycle + create-CTA placement rule (#109)
Implements Spec 091 “BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)”.

- BackupSchedule lifecycle:
  - Archive (soft delete) with confirmation; restores via Restore action; Force delete with confirmation and strict gating.
  - Force delete blocked when historical runs exist.
  - Archived schedules never dispatch/execute (dispatcher + job guard).
  - Audit events emitted for archive/restore/force delete.
  - RBAC UX semantics preserved (non-member hidden/404; member w/o capability disabled + server-side 403).

- Filament UX contract update:
  - Create CTA placement rule across create-enabled list pages:
    - Empty list: only large centered empty-state Create CTA.
    - Non-empty list: only header Create action.
  - Tests added/updated to enforce the rule.

Verification:
- `vendor/bin/sail bin pint --dirty`
- Focused tests: BackupScheduling + RBAC enforcement + EmptyState CTAs + Create CTA placement

Notes:
- Filament v5 / Livewire v4 compliant.
- Manual quickstart verification in `specs/091-backupschedule-retention-lifecycle/quickstart.md` remains to be checked (T031).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #109
2026-02-14 13:46:06 +00:00
90bfe1516e feat(spec-090): action surface contract compliance (#108)
Implements Spec 090 (Action Surface Contract Compliance & RBAC Hardening).

Highlights:
- Adds/updates action surface declarations and shrinks baseline exemptions.
- Standardizes Filament action grouping/order and empty-state CTAs.
- Enforces RBAC UX semantics (non-member -> 404, member w/o capability -> disabled + tooltip, server-side 403).
- Adds audit logging for successful side-effect actions.
- Fixes Provider Connections list context so header create + row actions resolve tenant correctly.

Tests (focused):
- vendor/bin/sail artisan test --compact tests/Feature/090/
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- vendor/bin/sail bin pint --dirty

Livewire/Filament:
- Filament v5 + Livewire v4 compliant.
- No panel provider registration changes (Laravel 11+ registration remains in bootstrap/providers.php).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #108
2026-02-13 01:30:22 +00:00
fb4de17c63 feat(spec-089): provider connections tenantless UI (#107)
Implements Spec 089: moves Provider Connections to canonical tenantless route under `/admin/provider-connections`, enforces 404/403 semantics (workspace/tenant membership vs capability), adds tenant transparency (tenant column + filter + deep links), adds legacy redirects for old tenant-scoped URLs without leaking Location for 404 cases, and adds regression test coverage (RBAC semantics, filters, UI enforcement tooltips, Microsoft-only MVP scope, navigation placement).

Notes:
- Filament v5 / Livewire v4 compatible.
- Global search remains disabled for Provider Connections.
- Destructive/manage actions require confirmation and are policy-gated.

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #107
2026-02-12 16:35:13 +00:00
d6e7de597a feat(spec-087): remove legacy runs (#106)
Implements Spec 087: Legacy Runs Removal (rigorous).

### What changed
- Canonicalized run history: **`operation_runs` is the only run system** for inventory sync, Entra group sync, backup schedule execution/retention/purge.
- Removed legacy UI surfaces (Filament Resources / relation managers) for legacy run models.
- Legacy run URLs now return **404** (no redirects), with RBAC semantics preserved (404 vs 403 as specified).
- Canonicalized affected `operation_runs.type` values (dotted → underscore) via migration.
- Drift + inventory references now point to canonical operation runs; includes backfills and then drops legacy FK columns.
- Drops legacy run tables after cutover.
- Added regression guards to prevent reintroducing legacy run tokens or “backfilling” canonical runs from legacy tables.

### Migrations
- `2026_02_12_000001..000006_*` canonicalize types, add/backfill operation_run_id references, drop legacy columns, and drop legacy run tables.

### Tests
Focused pack for this spec passed:
- `tests/Feature/Guards/NoLegacyRunsTest.php`
- `tests/Feature/Guards/NoLegacyRunBackfillTest.php`
- `tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php`
- `tests/Feature/Monitoring/MonitoringOperationsTest.php`
- `tests/Feature/Jobs/RunInventorySyncJobTest.php`

### Notes / impact
- Destructive cleanup is handled via migrations (drops legacy tables) after code cutover; deploy should run migrations in the same release.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #106
2026-02-12 12:40:51 +00:00
1acbf8cc54 feat(spec-088): remove tenant graphOptions legacy path (#105)
## Summary
- remove tenant-based Graph options access from runtime service paths and enforce provider-only resolution
- add `MicrosoftGraphOptionsResolver` and `ProviderConfigurationRequiredException` for centralized, actionable provider-config errors
- turn `Tenant::graphOptions()` into a fail-fast kill switch to prevent legacy runtime usage
- add and update tests (including guardrail) to enforce no reintroduction in `app/`
- update Spec 088 artifacts (`spec`, `plan`, `research`, `tasks`, checklist)

## Validation
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact --filter=NoLegacyTenantGraphOptions`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament`
- `CI=1 vendor/bin/sail artisan test --compact`

## Notes
- Branch includes the guardrail test for legacy callsite detection in `app/`.
- Full suite currently green: 1227 passed, 5 skipped.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #105
2026-02-12 10:14:44 +00:00
57f3e3934c 085-tenant-operate-hub (#104)
Summary
Implements Spec 085 “Tenant Operate Hub” semantics so central Monitoring pages are context-aware when entered from a tenant, without changing canonical URLs or implicitly mutating tenant selection. Also fixes a UX leak where tenant-scoped Inventory/Policies/Backups surfaces could appear in Admin navigation / be reachable without a selected tenant.

Why

Reduce “where am I / lost tenant context” confusion when operators jump between tenant work and central Monitoring.
Preserve deny-as-not-found security semantics and avoid tenant identity leaks.
Keep tenant-scoped data surfaces strictly tenant-scoped (not workspace-scoped).
What changed

Context-aware Monitoring:
/admin/operations shows scope label + CTAs (“Back to <tenant>”, “Show all tenants”) when tenant context is active and entitled.
/admin/operations/{run} shows deterministic back affordances + optional escape hatch (“Show all operations”) when tenant context is active and entitled.
Canonical Monitoring GET routes do not mutate tenant context.
Stale tenant context (not entitled) falls back to workspace scope without leaking tenant identity.
Tenant navigation IA:
Tenant panel sidebar provides “Monitoring” shortcuts (Runs/Alerts/Audit Log) into the central Monitoring surfaces.
Tenant-scoped Admin surfaces guard:
Inventory/Policies/Policy Versions/Backup Sets no longer show up tenantless; direct access redirects to /admin/choose-tenant when no tenant is selected.
Tests

Added/updated Pest coverage for:
Spec 085 header affordances + stale-context behavior
deny-as-not-found regressions for non-members/non-entitled users
“DB-only render” (no outbound calls) for Monitoring pages
tenant-scoped admin surfaces redirect when no tenant selected
Compatibility / Constraints

Filament v5 + Livewire v4 compliant (no v3 APIs).
Panel providers remain registered via providers.php (Laravel 11+/12).
No new assets; no changes to filament:assets deployment requirements.
No global search changes.
Manual verification

From a tenant, click “Monitoring → Runs” and confirm:
Scope label shows tenant scope
“Show all tenants” clears tenant context and returns to workspace scope
Open a run detail and confirm “Back to <tenant>” behavior + “Show all operations”.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #104
2026-02-11 21:01:23 +00:00
2bf5de4663 085-tenant-operate-hub (#103)
Summary

Consolidates the “Tenant Operate Hub” work (Spec 085) and the follow-up adjustments from the 086 session merge into a single branch ready to merge into dev.
Primary focus: stabilize Ops/Operate Hub UX flows, tighten/align authorization semantics, and make the full Sail test suite green.
Key Changes

Ops UX / Verification
Readonly members can view verification operation runs (reports) while starting verification remains restricted.
Normalized failure reason-code handling and aligned UX expectations with the provider reason-code taxonomy.
Onboarding wizard UX
“Start verification” CTA is hidden while a verification run is active; “Refresh” is shown during in-progress runs.
Treats provider_permission_denied as a blocking reason (while keeping legacy compatibility).
Test + fixture hardening
Standardized use of default provider connection fixtures in tests where sync/restore flows require it.
Fixed multiple Filament URL/tenant-context test cases to avoid 404s and reduce tenancy routing brittleness.
Policy sync / restore safety
Enrollment configuration type collision classification tests now exercise the real sync path (with required provider connection present).
Restore edge-case safety tests updated to reflect current provider-connection requirements.
Testing

vendor/bin/sail artisan test --compact (green)
vendor/bin/sail bin pint --dirty (green)
Notes

Includes merged 086 session work already (no separate PR needed).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #103
2026-02-11 13:02:03 +00:00
0e2adeab71 feat(verification): unify verification surfaces (Spec 084) (#102)
Implements Spec 084 (verification-surfaces-unification).

Highlights
- Unifies tenant + onboarding verification start on `provider.connection.check` (OperationRun-based, enqueue-only).
- Ensures completed blocked runs persist a schema-valid `context.verification_report` stub (DB-only viewers never show “unavailable”).
- Adds tenant embedded verification report widget with DB-only rendering + canonical tenantless “View run” links.
- Enforces 404/403 semantics for tenantless run viewing (workspace membership + tenant entitlement required; otherwise 404).
- Fixes admin panel widgets to resolve tenant from record context so Owners can start verification and recent operations renders correctly.

Tests
- Ran: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/TenantVerificationReportWidgetTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`

Notes
- Filament v5 / Livewire v4 compatible.
- No new assets; no changes to provider registration.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #102
2026-02-09 11:28:09 +00:00
55166cf9b8 Spec 083: Required permissions hardening (canonical /admin/tenants, DB-only, 404 semantics) (#101)
Implements Spec 083 (Canonical Required Permissions manage surface hardening + issues-first UX).

Highlights:
- Enforces canonical route: /admin/tenants/{tenant}/required-permissions
- Legacy tenant-plane URL /admin/t/{tenant}/required-permissions stays non-existent (404)
- Deny-as-not-found (404) for non-workspace members and non-tenant-entitled users
- Strict tenant resolution (no cross-plane fallback)
- DB-only render (no external provider calls on page load)
- Issues-first layout + canonical next-step links (re-run verification -> /admin/onboarding)
- Freshness/stale detection (missing or >30 days -> warning)

Tests (Sail):
- vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
- vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php

Notes:
- Filament v5 / Livewire v4 compliant.
- No destructive actions added in this spec; link-only CTAs.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #101
2026-02-08 23:13:25 +00:00
1085 changed files with 84215 additions and 8831 deletions

View File

@ -0,0 +1,167 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests

View File

@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

4
.codex/config.toml Normal file
View File

@ -0,0 +1,4 @@
[mcp_servers.laravel-boost]
command = "vendor/bin/sail"
args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"

View File

@ -22,6 +22,23 @@ ## Active Technologies
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin) - PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract) - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- 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)
- 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)
@ -41,10 +58,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 - 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 - 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 - 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 -->

167
.github/skills/pest-testing/SKILL.md vendored Normal file
View File

@ -0,0 +1,167 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests

View File

@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@ -1,17 +1,23 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.7.0 → 1.8.0 - Version change: 1.9.0 → 1.10.0
- Modified principles: - Modified principles:
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule) - Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
- Added sections: None - Added sections:
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
- 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
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md - ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo) - N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None - Follow-up TODOs:
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
--> -->
# TenantPilot Constitution # TenantPilot Constitution
@ -38,18 +44,42 @@ ### Deterministic Capabilities
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver. - Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior. - The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
### Workspace Isolation is Non-negotiable
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
deny-as-not-found (404).
- Workspace is the primary session context. Tenant-scoped routes/resources MUST require an established workspace context.
- Workspace context switching is separate from Filament Tenancy (Managed Tenant switching).
### Tenant Isolation is Non-negotiable ### Tenant Isolation is Non-negotiable
- Every read/write MUST be tenant-scoped. - Every tenant-plane read/write MUST be tenant-scoped.
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts). - Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
- Tenantless canonical views (e.g., Monitoring/Operations) MUST enforce tenant entitlement before revealing records.
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected. - Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as - Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
deny-as-not-found (404). deny-as-not-found (404).
Scope & Ownership Clarification (SCOPE-001)
- The system MUST enforce a strict ownership model:
- Workspace-owned objects define standards, templates, and global configuration (e.g., Baseline Profiles, Notification Targets, Alert Routing Rules, Framework/Control catalogs).
- Tenant-owned objects represent observed state, evidence, and operational artifacts for a specific tenant (e.g., Inventory, Backups/Snapshots, OperationRuns for tenant operations, Drift/Findings, Exceptions/Risk Acceptance, EvidenceItems, StoredReports/Exports).
- Workspace-owned objects MUST NOT directly embed or persist tenant-owned records (no “copying tenant data into templates”).
- Tenant-owned objects MUST always be bound to an established workspace + tenant scope at authorization time.
Database convention:
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
### RBAC & UI Enforcement Standards (RBAC-UX) ### RBAC & UI Enforcement Standards (RBAC-UX)
RBAC Context — Planes, Roles, and Auditability RBAC Context — Planes, Roles, and Auditability
- The platform MUST maintain two strictly separated authorization planes: - The platform MUST maintain two strictly separated authorization planes:
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped. - Tenant/Admin plane (`/admin`): authenticated Entra users (`users`).
- Tenant-context routes (`/admin/t/{tenant}/...`) are tenant-scoped.
- Workspace-context canonical routes (`/admin/...`, e.g. Monitoring/Operations) are tenantless by URL but MUST still enforce workspace + tenant entitlement before revealing tenant-owned records.
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped. - Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration. - Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
- Tenant role semantics MUST remain least-privilege: - Tenant role semantics MUST remain least-privilege:
@ -67,15 +97,15 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- Any missing server-side authorization is a P0 security bug. - Any missing server-side authorization is a P0 security bug.
RBAC-UX-002 — Deny-as-not-found for non-members RBAC-UX-002 — Deny-as-not-found for non-members
- Tenant membership (and plane membership) is an isolation boundary. - Tenant and workspace membership (and plane membership) are isolation boundaries.
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST - If the current actor is not a member of the current workspace OR the current tenant (or otherwise not entitled to the
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources. workspace/tenant scope), the system MUST respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all - This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
action endpoints (Livewire calls included). action endpoints (Livewire calls included).
RBAC-UX-003 — Capability denial is 403 (after membership is established) RBAC-UX-003 — Capability denial is 403 (after membership is established)
- Within an established tenant scope, missing permissions are authorization failures. - Within an established workspace + tenant scope, missing permissions are authorization failures.
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403. - If the actor is a workspace + tenant member, but lacks the required capability for an action, the server MUST fail with 403.
- The UI may render disabled actions, but the server MUST still enforce 403 on execution. - The UI may render disabled actions, but the server MUST still enforce 403 on execution.
RBAC-UX-004 — Visible vs disabled UX rule RBAC-UX-004 — Visible vs disabled UX rule
@ -97,9 +127,12 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- CI MUST fail if unknown/unregistered capabilities are used. - CI MUST fail if unknown/unregistered capabilities are used.
RBAC-UX-007 — Global search must be tenant-safe RBAC-UX-007 — Global search must be tenant-safe
- Global search results MUST be scoped to the current tenant. - Global search MUST be context-safe (workspace-context vs tenant-context).
- Non-members MUST never learn about resources in other tenants (no results, no hints). - Non-members MUST never learn about resources in other tenants (no results, no hints).
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics). - If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
- In workspace-context (no active tenant selected), Global Search MUST NOT return tenant-owned results.
- It MAY search workspace-owned objects only (e.g., Tenants list entries, Baseline Profiles, Alert Rules/Targets, workspace settings).
- If tenant-context is active, Global Search MUST be scoped to the current tenant only (existing rule remains).
RBAC-UX-008 — Regression guards are mandatory RBAC-UX-008 — Regression guards are mandatory
- The repo MUST include RBAC regression tests asserting at least: - The repo MUST include RBAC regression tests asserting at least:
@ -129,6 +162,72 @@ ### 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.
@ -147,11 +246,13 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column. - Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows. - Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable). - View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
- Create/Edit MUST provide consistent Save/Cancel UX. - Create/Edit MUST provide consistent Save/Cancel UX.
Grouping & safety Grouping & safety
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”. - Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
- Bulk actions MUST be grouped via BulkActionGroup. - Bulk actions MUST be grouped via BulkActionGroup.
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes. - Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
- Relevant mutations MUST write an audit log entry. - Relevant mutations MUST write an audit log entry.
@ -163,7 +264,50 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Spec / DoD gates Spec / DoD gates
- Every spec MUST include a “UI Action Matrix”. - Every spec MUST include a “UI Action Matrix”.
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason. - A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
- CI MUST enforce the contract (test/command) and block merges on violations. - CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
### Filament UI — Layout & Information Architecture Standards (UX-001)
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
Page layout
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
View pages
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
- Long text MUST render as readable prose (not textarea styling).
Empty states
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
Actions & flows
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
Table work-surface defaults
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
Enforcement
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare:
- Scope: workspace | tenant | canonical-view
- Primary Routes
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
- RBAC: membership requirements + capability requirements
- For canonical-view specs, the spec MUST define:
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
- Explicit entitlement checks that prevent cross-tenant leakage
### Data Minimization & Safe Logging ### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`. - Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -200,4 +344,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.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08 **Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23

View File

@ -35,16 +35,21 @@ ## Constitution Check
- Read/write separation: any writes require preview + confirmation + audit + tests - Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` - Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) - Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks) - RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text - RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- 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
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)

View File

@ -5,6 +5,18 @@ # Feature Specification: [FEATURE NAME]
**Status**: Draft **Status**: Draft
**Input**: User description: "$ARGUMENTS" **Input**: User description: "$ARGUMENTS"
## Spec Scope Fields *(mandatory)*
- **Scope**: [workspace | tenant | canonical-view]
- **Primary Routes**: [List the primary routes/pages affected]
- **Data Ownership**: [workspace-owned vs tenant-owned tables/records impacted]
- **RBAC**: [membership requirements + capability requirements]
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
## User Scenarios & Testing *(mandatory)* ## User Scenarios & Testing *(mandatory)*
<!-- <!--
@ -82,11 +94,18 @@ ## 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/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),
- explicitly define 404 vs 403 semantics: - explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found) - non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403 - member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, - describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), - reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
@ -103,7 +122,11 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, **Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
<!-- <!--
ACTION REQUIRED: The content in this section represents placeholders. ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements. Fill them out with the right functional requirements.

View File

@ -14,12 +14,20 @@ # 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:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found) - non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403, - member but missing capability → 403,
- capability registry usage (no raw capability strings; no role-string checks in feature code), - capability registry usage (no raw capability strings; no role-string checks in feature code),
- stating which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics), - tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side), - destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
- cross-plane deny-as-not-found (404) checks where applicable, - cross-plane deny-as-not-found (404) checks where applicable,
@ -33,6 +41,14 @@ # Tasks: [FEATURE NAME]
- adding confirmations for destructive actions (and typed confirmation where required by scale), - adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations, - adding `AuditLog` entries for relevant mutations,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale. - adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped),
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values. avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

357
Agents.md
View File

@ -389,6 +389,7 @@ ## Reference Materials
=== .ai/filament-v5-blueprint rules === === .ai/filament-v5-blueprint rules ===
## Source of Truth ## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in: If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md - docs/research/filament-v5-notes.md
and prefer that over guesses. and prefer that over guesses.
@ -398,6 +399,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5) # Filament Blueprint (v5)
## 1) Non-negotiables ## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+. - Filament v5 requires Livewire v4.0+.
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`). - Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results. - Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
@ -413,6 +415,7 @@ ## 1) Non-negotiables
- https://filamentphp.com/docs/5.x/styling/css-hooks - https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions ## 2) Directory & naming conventions
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture. - Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`. - Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
@ -421,6 +424,7 @@ ## 2) Directory & naming conventions
- https://filamentphp.com/docs/5.x/advanced/modular-architecture - https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults ## 3) Panel setup defaults
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels. - Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel. - Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review. - Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
@ -434,6 +438,7 @@ ## 3) Panel setup defaults
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture ## 4) Navigation & information architecture
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately. - Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows. - Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule. - Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
@ -447,6 +452,7 @@ ## 4) Navigation & information architecture
- https://filamentphp.com/docs/5.x/navigation/user-menu - https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns ## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows. - Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search: - Global search:
- If a resource is intended for global search: ensure Edit/View page exists. - If a resource is intended for global search: ensure Edit/View page exists.
@ -459,6 +465,7 @@ ## 5) Resource patterns
- https://filamentphp.com/docs/5.x/resources/global-search - https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules ## 6) Page lifecycle & query rules
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading. - Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
- Prefer render hooks for layout injection; avoid publishing internal views. - Prefer render hooks for layout injection; avoid publishing internal views.
@ -467,6 +474,7 @@ ## 6) Page lifecycle & query rules
- https://filamentphp.com/docs/5.x/advanced/render-hooks - https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree) ## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager. - Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields. - Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater. - Inline CRUD inside owner form → Repeater.
@ -477,6 +485,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
- https://filamentphp.com/docs/5.x/infolists/overview - https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state) ## 8) Form patterns (validation, reactivity, state)
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side. - Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns). - Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
- Custom field views must obey state binding modifiers. - Custom field views must obey state binding modifiers.
@ -486,6 +495,7 @@ ## 8) Form patterns (validation, reactivity, state)
- https://filamentphp.com/docs/5.x/forms/custom-fields - https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns ## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate). - Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions: - Actions:
- Execution actions use `->action(...)`. - Execution actions use `->action(...)`.
@ -498,6 +508,7 @@ ## 9) Table & action patterns
- https://filamentphp.com/docs/5.x/actions/modals - https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security ## 10) Authorization & security
- Enforce panel access in non-local environments as documented. - Enforce panel access in non-local environments as documented.
- UI visibility is not security; enforce policies/access checks in addition to hiding UI. - UI visibility is not security; enforce policies/access checks in addition to hiding UI.
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization. - Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
@ -507,6 +518,7 @@ ## 10) Authorization & security
- https://filamentphp.com/docs/5.x/resources/deleting-records - https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback ## 11) Notifications & UX feedback
- Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious. - Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used. - Treat polling as a cost; set intervals intentionally where polling is used.
@ -515,6 +527,7 @@ ## 11) Notifications & UX feedback
- https://filamentphp.com/docs/5.x/widgets/stats-overview - https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults ## 12) Performance defaults
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies. - Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing. - Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
@ -524,6 +537,7 @@ ## 12) Performance defaults
- https://filamentphp.com/docs/5.x/advanced/render-hooks - https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements ## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components. - Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance. - Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests. - Do not mount non-Livewire classes in Livewire tests.
@ -533,6 +547,7 @@ ## 13) Testing requirements
- https://filamentphp.com/docs/5.x/testing/testing-actions - https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns ## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code. - Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5. - Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+. - Registering panel providers in `bootstrap/app.php` on Laravel 11+.
@ -547,6 +562,7 @@ ## 14) Forbidden patterns
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract ## 15) Agent output contract
For any implementation request, the agent must explicitly state: For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance. 1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`). 2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
@ -567,6 +583,7 @@ ## 15) Agent output contract
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety ## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere). - [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire” - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs). - [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
@ -574,6 +591,7 @@ ## Version Safety
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements” - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation ## Panel & Navigation
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`). - [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel” - Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`). - [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
@ -588,6 +606,7 @@ ## Panel & Navigation
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction” - Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure ## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search. - [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles” - Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it. - [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
@ -596,18 +615,21 @@ ## Resource Structure
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results” - Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations ## Infolists & Relations
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction. - [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job” - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
- [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification. - [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading” - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
## Forms ## Forms
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily. - [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur” - Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers). - [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers” - Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
## Tables & Actions ## Tables & Actions
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate). - [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions” - Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`. - [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
@ -616,6 +638,7 @@ ## Tables & Actions
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals” - Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security ## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented. - [ ] Panel access is enforced for non-local environments as documented.
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel” - Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries. - [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
@ -623,24 +646,28 @@ ## Authorization & Security
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization” - Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications ## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious. - [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction” - Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load. - [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)” - Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
## Performance ## Performance
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate. - [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default). - [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes” - Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
## Testing ## Testing
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes. - [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?” - Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance. - [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions” - Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
@ -648,12 +675,13 @@ ## Deployment / Ops
# Laravel Boost Guidelines # Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.15 - php - 8.4.1
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -666,56 +694,73 @@ ## Foundational Context
- phpunit/phpunit (PHPUNIT) - v12 - phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
## Verification Scripts ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules === === boost rules ===
## Laravel Boost # Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database. - Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool ## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
@ -725,38 +770,44 @@ ### Available Search Syntax
=== php rules === === php rules ===
## PHP # PHP
- Always use curly braces for control structures, even if it has one line. - Always use curly braces for control structures, even for single-line bodies.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ## Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters. - Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php"> <!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool protected function isAccessible(User $user, ?string $path = null): bool
{ {
... ...
} }
</code-snippet> ```
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== sail rules === === sail rules ===
## Laravel Sail # Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@ -770,20 +821,21 @@ ## Laravel Sail
=== tests rules === === tests rules ===
## Test Enforcement # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way # Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries. - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@ -791,43 +843,53 @@ ### Database
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation ## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules. - Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues ## Authentication & Authorization
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation ## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function. - When generating links to other pages, prefer named routes and the `route()` function.
### Configuration ## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing ## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 # Laravel 12
- Use the `search-docs` tool to get version-specific documentation. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@ -835,224 +897,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules === === pint/core rules ===
## Laravel Pint Code Formatter # Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests - This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. - Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - Do NOT delete tests without approval.
- Tests should test all of the happy paths, failure paths, and weird paths. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- Tests live in the `tests/Feature` and `tests/Unit` directories. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind CSS # Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own. - Always use existing Tailwind conventions; check project patterns before adding new ones.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.). - IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically. - IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines> </laravel-boost-guidelines>
## Active Technologies ## Active Technologies

357
GEMINI.md
View File

@ -229,6 +229,7 @@ ## Reference Materials
=== .ai/filament-v5-blueprint rules === === .ai/filament-v5-blueprint rules ===
## Source of Truth ## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in: If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md - docs/research/filament-v5-notes.md
and prefer that over guesses. and prefer that over guesses.
@ -238,6 +239,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5) # Filament Blueprint (v5)
## 1) Non-negotiables ## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+. - Filament v5 requires Livewire v4.0+.
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`). - Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results. - Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
@ -253,6 +255,7 @@ ## 1) Non-negotiables
- https://filamentphp.com/docs/5.x/styling/css-hooks - https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions ## 2) Directory & naming conventions
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture. - Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`. - Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
@ -261,6 +264,7 @@ ## 2) Directory & naming conventions
- https://filamentphp.com/docs/5.x/advanced/modular-architecture - https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults ## 3) Panel setup defaults
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels. - Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel. - Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review. - Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
@ -274,6 +278,7 @@ ## 3) Panel setup defaults
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture ## 4) Navigation & information architecture
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately. - Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows. - Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule. - Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
@ -287,6 +292,7 @@ ## 4) Navigation & information architecture
- https://filamentphp.com/docs/5.x/navigation/user-menu - https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns ## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows. - Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search: - Global search:
- If a resource is intended for global search: ensure Edit/View page exists. - If a resource is intended for global search: ensure Edit/View page exists.
@ -299,6 +305,7 @@ ## 5) Resource patterns
- https://filamentphp.com/docs/5.x/resources/global-search - https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules ## 6) Page lifecycle & query rules
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading. - Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
- Prefer render hooks for layout injection; avoid publishing internal views. - Prefer render hooks for layout injection; avoid publishing internal views.
@ -307,6 +314,7 @@ ## 6) Page lifecycle & query rules
- https://filamentphp.com/docs/5.x/advanced/render-hooks - https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree) ## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager. - Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields. - Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater. - Inline CRUD inside owner form → Repeater.
@ -317,6 +325,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
- https://filamentphp.com/docs/5.x/infolists/overview - https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state) ## 8) Form patterns (validation, reactivity, state)
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side. - Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns). - Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
- Custom field views must obey state binding modifiers. - Custom field views must obey state binding modifiers.
@ -326,6 +335,7 @@ ## 8) Form patterns (validation, reactivity, state)
- https://filamentphp.com/docs/5.x/forms/custom-fields - https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns ## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate). - Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions: - Actions:
- Execution actions use `->action(...)`. - Execution actions use `->action(...)`.
@ -338,6 +348,7 @@ ## 9) Table & action patterns
- https://filamentphp.com/docs/5.x/actions/modals - https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security ## 10) Authorization & security
- Enforce panel access in non-local environments as documented. - Enforce panel access in non-local environments as documented.
- UI visibility is not security; enforce policies/access checks in addition to hiding UI. - UI visibility is not security; enforce policies/access checks in addition to hiding UI.
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization. - Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
@ -347,6 +358,7 @@ ## 10) Authorization & security
- https://filamentphp.com/docs/5.x/resources/deleting-records - https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback ## 11) Notifications & UX feedback
- Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious. - Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used. - Treat polling as a cost; set intervals intentionally where polling is used.
@ -355,6 +367,7 @@ ## 11) Notifications & UX feedback
- https://filamentphp.com/docs/5.x/widgets/stats-overview - https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults ## 12) Performance defaults
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies. - Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing. - Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
@ -364,6 +377,7 @@ ## 12) Performance defaults
- https://filamentphp.com/docs/5.x/advanced/render-hooks - https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements ## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components. - Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance. - Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests. - Do not mount non-Livewire classes in Livewire tests.
@ -373,6 +387,7 @@ ## 13) Testing requirements
- https://filamentphp.com/docs/5.x/testing/testing-actions - https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns ## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code. - Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5. - Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+. - Registering panel providers in `bootstrap/app.php` on Laravel 11+.
@ -387,6 +402,7 @@ ## 14) Forbidden patterns
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract ## 15) Agent output contract
For any implementation request, the agent must explicitly state: For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance. 1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`). 2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
@ -407,6 +423,7 @@ ## 15) Agent output contract
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety ## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere). - [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire” - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs). - [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
@ -414,6 +431,7 @@ ## Version Safety
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements” - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation ## Panel & Navigation
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`). - [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel” - Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`). - [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
@ -428,6 +446,7 @@ ## Panel & Navigation
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction” - Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure ## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search. - [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles” - Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it. - [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
@ -436,18 +455,21 @@ ## Resource Structure
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results” - Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations ## Infolists & Relations
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction. - [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job” - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
- [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification. - [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading” - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
## Forms ## Forms
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily. - [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur” - Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers). - [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers” - Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
## Tables & Actions ## Tables & Actions
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate). - [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions” - Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`. - [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
@ -456,6 +478,7 @@ ## Tables & Actions
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals” - Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security ## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented. - [ ] Panel access is enforced for non-local environments as documented.
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel” - Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries. - [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
@ -463,24 +486,28 @@ ## Authorization & Security
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization” - Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications ## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious. - [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction” - Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load. - [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)” - Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
## Performance ## Performance
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate. - [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default). - [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes” - Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
## Testing ## Testing
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes. - [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?” - Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance. - [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions” - Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
@ -488,12 +515,13 @@ ## Deployment / Ops
# Laravel Boost Guidelines # Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.15 - php - 8.4.1
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
@ -506,56 +534,73 @@ ## Foundational Context
- phpunit/phpunit (PHPUNIT) - v12 - phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
## Verification Scripts ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules === === boost rules ===
## Laravel Boost # Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database. - Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool ## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
@ -565,38 +610,44 @@ ### Available Search Syntax
=== php rules === === php rules ===
## PHP # PHP
- Always use curly braces for control structures, even if it has one line. - Always use curly braces for control structures, even for single-line bodies.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ## Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters. - Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php"> <!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool protected function isAccessible(User $user, ?string $path = null): bool
{ {
... ...
} }
</code-snippet> ```
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== sail rules === === sail rules ===
## Laravel Sail # Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@ -610,20 +661,21 @@ ## Laravel Sail
=== tests rules === === tests rules ===
## Test Enforcement # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way # Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries. - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@ -631,43 +683,53 @@ ### Database
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation ## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules. - Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues ## Authentication & Authorization
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation ## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function. - When generating links to other pages, prefer named routes and the `route()` function.
### Configuration ## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing ## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 # Laravel 12
- Use the `search-docs` tool to get version-specific documentation. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@ -675,224 +737,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules === === pint/core rules ===
## Laravel Pint Code Formatter # Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests - This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. - Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - Do NOT delete tests without approval.
- Tests should test all of the happy paths, failure paths, and weird paths. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- Tests live in the `tests/Feature` and `tests/Unit` directories. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind CSS # Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own. - Always use existing Tailwind conventions; check project patterns before adding new ones.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.). - IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically. - IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines> </laravel-boost-guidelines>
## Recent Changes ## Recent Changes

View File

@ -3,6 +3,7 @@
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
@ -11,7 +12,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): int public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int
{ {
$contracts = config('graph_contracts.types', []); $contracts = config('graph_contracts.types', []);
@ -36,11 +37,13 @@ public function handle(GraphClientInterface $graph): int
continue; continue;
} }
$query = array_filter([ $queryInput = 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

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

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

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

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillWorkspaceIdsJob;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class TenantpilotBackfillWorkspaceIds extends Command
{
protected $signature = 'tenantpilot:backfill-workspace-ids
{--dry-run : Print per-table counts only}
{--table= : Restrict to a single tenant-owned table}
{--batch-size=5000 : Rows per queued chunk}
{--resume-from=0 : Resume from id cursor}
{--max-rows= : Maximum rows to process per table job}';
protected $description = 'Backfill missing workspace_id across tenant-owned tables.';
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): int
{
$tables = $this->resolveTables();
if ($tables === []) {
return self::FAILURE;
}
$batchSize = max(1, (int) $this->option('batch-size'));
$resumeFrom = max(0, (int) $this->option('resume-from'));
$maxRows = $this->normalizeMaxRows();
$dryRun = (bool) $this->option('dry-run');
$lock = Cache::lock('tenantpilot:backfill-workspace-ids', 900);
if (! $lock->get()) {
$this->error('Another workspace backfill is already running.');
return self::FAILURE;
}
try {
$tableStats = $this->collectTableStats($tables);
$this->table(
['Table', 'Missing workspace_id', 'Unresolvable tenant mapping', 'Sample row ids'],
array_map(static function (array $stats): array {
return [
$stats['table'],
$stats['missing'],
$stats['unresolvable'],
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
];
}, $tableStats),
);
$unresolvable = array_values(array_filter($tableStats, static fn (array $stats): bool => $stats['unresolvable'] > 0));
if ($unresolvable !== []) {
foreach ($unresolvable as $stats) {
$this->error(sprintf(
'Unresolvable tenant->workspace mapping in %s (%d rows). Sample ids: %s',
$stats['table'],
$stats['unresolvable'],
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
));
}
return self::FAILURE;
}
if ($dryRun) {
$this->info('Dry-run complete. No changes written.');
return self::SUCCESS;
}
$workspaceWorkloads = $this->collectWorkspaceWorkloads($tables, $maxRows);
if ($workspaceWorkloads === []) {
$this->info('No rows require workspace_id backfill.');
return self::SUCCESS;
}
$dispatchedJobs = 0;
foreach ($workspaceWorkloads as $workspaceId => $workload) {
$workspace = Workspace::query()->find($workspaceId);
if (! $workspace instanceof Workspace) {
continue;
}
$run = $operationRunService->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'workspace_isolation_backfill_workspace_ids',
identityInputs: [
'tables' => array_keys($workload['tables']),
],
context: [
'source' => 'tenantpilot:backfill-workspace-ids',
'workspace_id' => (int) $workspace->getKey(),
'batch_size' => $batchSize,
'max_rows' => $maxRows,
'resume_from' => $resumeFrom,
'tables' => array_keys($workload['tables']),
],
);
if (! $run->wasRecentlyCreated) {
$this->line(sprintf(
'Workspace %d already has an active backfill run (#%d).',
(int) $workspace->getKey(),
(int) $run->getKey(),
));
continue;
}
$tableProgress = [];
foreach ($workload['tables'] as $table => $count) {
$tableProgress[$table] = [
'target_rows' => (int) $count,
'processed' => 0,
'last_processed_id' => $resumeFrom,
];
}
$context = is_array($run->context) ? $run->context : [];
$context['table_progress'] = $tableProgress;
$run->update([
'context' => $context,
'summary_counts' => [
'total' => (int) $workload['total'],
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
],
]);
$operationRunService->updateRun($run, status: 'running');
$workspaceAuditLogger->log(
workspace: $workspace,
action: 'workspace_isolation.backfill_workspace_ids.started',
context: [
'operation_run_id' => (int) $run->getKey(),
'tables' => array_keys($workload['tables']),
'planned_rows' => (int) $workload['total'],
'batch_size' => $batchSize,
],
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
$workspaceJobs = 0;
foreach ($workload['tables'] as $table => $tableRows) {
if ($tableRows <= 0) {
continue;
}
BackfillWorkspaceIdsJob::dispatch(
operationRunId: (int) $run->getKey(),
workspaceId: (int) $workspace->getKey(),
table: $table,
batchSize: $batchSize,
maxRows: $maxRows,
resumeFrom: $resumeFrom,
);
$workspaceJobs++;
$dispatchedJobs++;
}
$workspaceAuditLogger->log(
workspace: $workspace,
action: 'workspace_isolation.backfill_workspace_ids.dispatched',
context: [
'operation_run_id' => (int) $run->getKey(),
'jobs_dispatched' => $workspaceJobs,
'tables' => array_keys($workload['tables']),
],
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
$this->line(sprintf(
'Workspace %d run #%d queued (%d job(s)).',
(int) $workspace->getKey(),
(int) $run->getKey(),
$workspaceJobs,
));
}
$this->info(sprintf('Backfill jobs dispatched: %d', $dispatchedJobs));
return self::SUCCESS;
} finally {
$lock->release();
}
}
/**
* @return array<int, string>
*/
private function resolveTables(): array
{
$selectedTable = $this->option('table');
if (! is_string($selectedTable) || trim($selectedTable) === '') {
return TenantOwnedTables::all();
}
$selectedTable = trim($selectedTable);
if (! TenantOwnedTables::contains($selectedTable)) {
$this->error(sprintf('Unknown tenant-owned table: %s', $selectedTable));
return [];
}
return [$selectedTable];
}
private function normalizeMaxRows(): ?int
{
$maxRows = $this->option('max-rows');
if (! is_numeric($maxRows)) {
return null;
}
$maxRows = (int) $maxRows;
return $maxRows > 0 ? $maxRows : null;
}
/**
* @param array<int, string> $tables
* @return array<int, array{table: string, missing: int, unresolvable: int, sample_ids: array<int, int>}>
*/
private function collectTableStats(array $tables): array
{
$stats = [];
foreach ($tables as $table) {
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
$unresolvableQuery = DB::table($table)
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->where(function ($query): void {
$query->whereNull('tenants.id')
->orWhereNull('tenants.workspace_id');
});
$unresolvable = (int) $unresolvableQuery->count();
$sampleIds = DB::table($table)
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->where(function ($query): void {
$query->whereNull('tenants.id')
->orWhereNull('tenants.workspace_id');
})
->orderBy(sprintf('%s.id', $table))
->limit(5)
->pluck(sprintf('%s.id', $table))
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$stats[] = [
'table' => $table,
'missing' => $missing,
'unresolvable' => $unresolvable,
'sample_ids' => $sampleIds,
];
}
return $stats;
}
/**
* @param array<int, string> $tables
* @return array<int, array{total: int, tables: array<string, int>}>
*/
private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
{
$workloads = [];
foreach ($tables as $table) {
$rows = DB::table($table)
->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->whereNotNull('tenants.workspace_id')
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
->groupBy('tenants.workspace_id')
->get();
foreach ($rows as $row) {
$workspaceId = (int) $row->workspace_id;
if ($workspaceId <= 0) {
continue;
}
$rowCount = (int) $row->row_count;
if ($maxRows !== null) {
$rowCount = min($rowCount, $maxRows);
}
if ($rowCount <= 0) {
continue;
}
if (! isset($workloads[$workspaceId])) {
$workloads[$workspaceId] = [
'total' => 0,
'tables' => [],
];
}
$workloads[$workspaceId]['tables'][$table] = $rowCount;
$workloads[$workspaceId]['total'] += $rowCount;
}
}
return $workloads;
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\Workspace;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class TenantpilotDispatchAlerts extends Command
{
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
public function handle(OperationRunService $operationRuns): int
{
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
return self::SUCCESS;
}
$workspaceFilter = array_values(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
(array) $this->option('workspace'),
)));
$workspaces = $this->resolveWorkspaces($workspaceFilter);
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
$queuedEvaluate = 0;
$queuedDeliver = 0;
$skippedEvaluate = 0;
$skippedDeliver = 0;
foreach ($workspaces as $workspace) {
$evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'alerts.evaluate',
identityInputs: ['slot_key' => $slotKey],
context: [
'trigger' => 'scheduled_dispatch',
'slot_key' => $slotKey,
],
initiator: null,
);
if ($evaluateRun->wasRecentlyCreated) {
EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey());
$queuedEvaluate++;
} else {
$skippedEvaluate++;
}
$deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'alerts.deliver',
identityInputs: ['slot_key' => $slotKey],
context: [
'trigger' => 'scheduled_dispatch',
'slot_key' => $slotKey,
],
initiator: null,
);
if ($deliverRun->wasRecentlyCreated) {
DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey());
$queuedDeliver++;
} else {
$skippedDeliver++;
}
}
$this->info(sprintf(
'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.',
$workspaces->count(),
$queuedEvaluate,
$skippedEvaluate,
$queuedDeliver,
$skippedDeliver,
));
return self::SUCCESS;
}
/**
* @param array<int, int> $workspaceIds
* @return Collection<int, Workspace>
*/
private function resolveWorkspaces(array $workspaceIds): Collection
{
return Workspace::query()
->when(
$workspaceIds !== [],
fn ($query) => $query->whereIn('id', $workspaceIds),
fn ($query) => $query->whereHas('tenants'),
)
->orderBy('id')
->get();
}
}

View File

@ -3,9 +3,9 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TenantpilotDispatchDirectoryGroupsSync extends Command class TenantpilotDispatchDirectoryGroupsSync extends Command
{ {
@ -46,27 +46,38 @@ public function handle(): int
$skipped = 0; $skipped = 0;
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([ /** @var OperationRunService $opService */
'tenant_id' => $tenant->getKey(), $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'slot_key' => $slotKey, 'slot_key' => $slotKey,
'status' => 'pending', ],
'initiator_user_id' => null, context: [
'created_at' => $now, 'selection_key' => $selectionKey,
'updated_at' => $now, 'slot_key' => $slotKey,
]); 'trigger' => 'scheduled',
],
initiator: null,
);
if (! $opRun->wasRecentlyCreated) {
$skipped++;
continue;
}
if ($inserted === 1) {
$created++; $created++;
dispatch(new \App\Jobs\EntraGroupSyncJob( dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(), tenantId: $tenant->getKey(),
selectionKey: $selectionKey, selectionKey: $selectionKey,
slotKey: $slotKey, slotKey: $slotKey,
runId: null,
operationRun: $opRun,
)); ));
} else {
$skipped++;
}
} }
$this->info(sprintf( $this->info(sprintf(

View File

@ -5,7 +5,6 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
@ -14,6 +13,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command class TenantpilotPurgeNonPersistentData extends Command
@ -80,10 +80,6 @@ public function handle(): int
} }
DB::transaction(function () use ($tenant): void { DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query() BackupSchedule::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->delete(); ->delete();
@ -117,6 +113,8 @@ public function handle(): int
->delete(); ->delete();
}); });
$this->recordPurgeOperationRun($tenant, $counts);
$this->info('Purged.'); $this->info('Purged.');
} }
@ -150,7 +148,6 @@ private function resolveTenants()
private function countsForTenant(Tenant $tenant): array private function countsForTenant(Tenant $tenant): array
{ {
return [ return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(), 'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
@ -161,4 +158,39 @@ private function countsForTenant(Tenant $tenant): array
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(), 'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
]; ];
} }
/**
* @param array<string, int> $counts
*/
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
{
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_purge',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id,
'backup_schedule_purge',
now()->toISOString(),
Str::uuid()->toString(),
])),
'summary_counts' => [
'total' => array_sum($counts),
'processed' => array_sum($counts),
'succeeded' => array_sum($counts),
'failed' => 0,
],
'failure_summary' => [],
'context' => [
'source' => 'tenantpilot:purge-nonpersistent',
'deleted_rows' => $counts,
],
'started_at' => now(),
'completed_at' => now(),
]);
}
} }

View File

@ -2,11 +2,11 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\BackupScheduleRun; use App\Models\BackupSchedule;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OperationRunOutcome;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{--older-than=5 : Only reconcile runs older than N minutes} {--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}'; {--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.'; protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int public function handle(OperationRunService $operationRunService): int
{ {
@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query() $query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) ->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']); ->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) { if ($olderThanMinutes > 0) {
@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int
$failed = 0; $failed = 0;
foreach ($query->cursor() as $operationRun) { foreach ($query->cursor() as $operationRun) {
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id'); $backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
if (! is_numeric($backupScheduleRunId)) { if (! is_numeric($backupScheduleId)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! $dryRun) { if (! $dryRun) {
$operationRunService->updateRun( $operationRunService->updateRun(
$operationRun, $operationRun,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Failed->value,
failures: [ failures: [
[ [
'code' => 'backup_schedule_run.not_found', 'code' => 'backup_schedule.missing_context',
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'), 'message' => 'Backup schedule context is missing from this operation run.',
], ],
], ],
); );
@ -82,118 +71,62 @@ public function handle(OperationRunService $operationRunService): int
continue; continue;
} }
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) { $schedule = BackupSchedule::query()
->whereKey((int) $backupScheduleId)
->where('tenant_id', (int) $operationRun->tenant_id)
->first();
if (! $schedule instanceof BackupSchedule) {
if (! $dryRun) { if (! $dryRun) {
$operationRunService->updateRun($operationRun, 'running', 'pending');
if ($scheduleRun->started_at) {
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
}
}
$reconciled++;
continue;
}
$outcome = match ($scheduleRun->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
BackupScheduleRun::STATUS_CANCELED => 'failed',
default => 'failed',
};
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
$syncFailures = $summary['sync_failures'] ?? [];
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
$processed = $policiesBackedUp + $syncFailuresCount;
if ($policiesTotal > 0) {
$processed = min($policiesTotal, $processed);
}
$summaryCounts = array_filter([
'total' => $policiesTotal,
'processed' => $processed,
'succeeded' => $policiesBackedUp,
'failed' => $syncFailuresCount,
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
'items' => $policiesTotal,
], fn (mixed $value): bool => is_int($value) && $value !== 0);
$failures = [];
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
$failures[] = [
'code' => 'backup_schedule_run.cancelled',
'message' => 'Backup schedule run was cancelled.',
];
}
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
$failures[] = [
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
];
}
if (is_array($syncFailures)) {
foreach ($syncFailures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $status !== null
? "{$policyType}: Graph returned {$status}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failures[] = [
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
'message' => RunFailureSanitizer::sanitizeMessage($message),
];
}
}
if (! $dryRun) {
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
]),
]);
$operationRunService->updateRun( $operationRunService->updateRun(
$operationRun, $operationRun,
status: 'completed', status: 'completed',
outcome: $outcome, outcome: OperationRunOutcome::Failed->value,
summaryCounts: $summaryCounts, failures: [
failures: $failures, [
'code' => 'backup_schedule.not_found',
'message' => 'Backup schedule not found for this operation run.',
],
],
); );
}
$operationRun->forceFill([ $failed++;
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at, continue;
])->save(); }
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
if (! $dryRun) {
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
} }
$reconciled++; $reconciled++;
continue;
}
if ($operationRun->status === 'running') {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.stalled',
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
],
],
);
}
$reconciled++;
continue;
}
$skipped++;
} }
$this->info(sprintf( $this->info(sprintf(

View File

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

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

@ -0,0 +1,17 @@
<?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,10 +7,15 @@
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

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Filament\Clusters\Monitoring;
use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class AlertsCluster extends Cluster
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?int $navigationSort = 20;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
public static function shouldRegisterNavigation(): bool
{
return Filament::getCurrentPanel()?->getId() === 'admin';
}
}

View File

@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
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\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Compare';
protected static ?int $navigationSort = 10;
protected static ?string $title = 'Baseline Compare';
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|null */
public ?array $severityCounts = null;
public ?string $lastComparedAt = null;
public static function canAccess(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
$this->state = 'no_tenant';
$this->message = 'No tenant selected.';
return;
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
$this->state = 'no_assignment';
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
return;
}
$profile = $assignment->baselineProfile;
if ($profile === null) {
$this->state = 'no_assignment';
$this->message = 'The assigned baseline profile no longer exists.';
return;
}
$this->profileName = (string) $profile->name;
$this->profileId = (int) $profile->getKey();
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
if ($this->snapshotId === null) {
$this->state = 'no_snapshot';
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
return;
}
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->latest('id')
->first();
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
$this->state = 'comparing';
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'A baseline comparison is currently in progress.';
return;
}
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
}
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey);
$totalFindings = (int) (clone $findingsQuery)->count();
if ($totalFindings > 0) {
$this->state = 'ready';
$this->findingsCount = $totalFindings;
$this->severityCounts = [
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
];
if ($latestRun instanceof OperationRun) {
$this->operationRunId = (int) $latestRun->getKey();
}
return;
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
$this->state = 'ready';
$this->findingsCount = 0;
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
return;
}
$this->state = 'idle';
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
}
private function compareNowAction(): Action
{
return Action::make('compareNow')
->label('Compare Now')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Start baseline comparison')
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
->visible(fn (): bool => $this->canCompare())
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
Notification::make()->title('Not authenticated')->danger()->send();
return;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
Notification::make()->title('No tenant context')->danger()->send();
return;
}
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) {
Notification::make()
->title('Cannot start comparison')
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if ($run instanceof OperationRun) {
$this->operationRunId = (int) $run->getKey();
}
$this->state = 'comparing';
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $tenant)),
] : [])
->send();
});
}
private function canCompare(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public function getFindingsUrl(): ?string
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl('index', tenant: $tenant);
}
public function getRunUrl(): ?string
{
if ($this->operationRunId === null) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return OperationRunLinks::view($this->operationRunId, $tenant);
}
}

View File

@ -27,6 +27,17 @@ 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,10 +7,11 @@
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 Filament\Actions\Action; use App\Support\Workspaces\WorkspaceRedirectResolver;
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;
@ -30,33 +31,18 @@ class ChooseWorkspace extends Page
protected string $view = 'filament.pages.choose-workspace'; protected string $view = 'filament.pages.choose-workspace';
/** /**
* @return array<Action> * Workspace roles keyed by workspace_id.
*
* @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)),
];
} }
/** /**
@ -70,15 +56,28 @@ public function getWorkspaces(): Collection
return Workspace::query()->whereRaw('1 = 0')->get(); return Workspace::query()->whereRaw('1 = 0')->get();
} }
return Workspace::query() $workspaces = 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
@ -105,11 +104,35 @@ 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());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); /** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
} }
/** /**
@ -147,41 +170,9 @@ public function createWorkspace(array $data): void
$intendedUrl = WorkspaceIntendedUrl::consume(request()); $intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); /** @var WorkspaceRedirectResolver $resolver */
} $resolver = app(WorkspaceRedirectResolver::class);
private function redirectAfterWorkspaceSelected(User $user): string $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
{
$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

@ -3,10 +3,8 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob; use App\Jobs\GenerateDriftFindingsJob;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -16,11 +14,12 @@
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
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;
@ -28,7 +27,7 @@ class DriftLanding extends Page
{ {
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
protected static string|UnitEnum|null $navigationGroup = 'Drift'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Drift'; protected static ?string $navigationLabel = 'Drift';
@ -67,21 +66,35 @@ public function mount(): void
abort(403, 'Not allowed'); abort(403, 'Not allowed');
} }
$latestSuccessful = InventorySyncRun::query() $latestSuccessful = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('status', InventorySyncRun::STATUS_SUCCESS) ->where('type', 'inventory_sync')
->whereNotNull('finished_at') ->where('status', OperationRunStatus::Completed->value)
->orderByDesc('finished_at') ->whereIn('outcome', [
OperationRunOutcome::Succeeded->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->whereNotNull('completed_at')
->orderByDesc('completed_at')
->first(); ->first();
if (! $latestSuccessful instanceof InventorySyncRun) { if (! $latestSuccessful instanceof OperationRun) {
$this->state = 'blocked'; $this->state = 'blocked';
$this->message = 'No successful inventory runs found yet.'; $this->message = 'No successful inventory runs found yet.';
return; return;
} }
$scopeKey = (string) $latestSuccessful->selection_hash; $latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
if ($scopeKey === '') {
$this->state = 'blocked';
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
return;
}
$this->scopeKey = $scopeKey; $this->scopeKey = $scopeKey;
$selector = app(DriftRunSelector::class); $selector = app(DriftRunSelector::class);
@ -100,15 +113,15 @@ public function mount(): void
$this->baselineRunId = (int) $baseline->getKey(); $this->baselineRunId = (int) $baseline->getKey();
$this->currentRunId = (int) $current->getKey(); $this->currentRunId = (int) $current->getKey();
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString(); $this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
$this->currentFinishedAt = $current->finished_at?->toDateTimeString(); $this->currentFinishedAt = $current->completed_at?->toDateTimeString();
$existingOperationRun = OperationRun::query() $existingOperationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate') ->where('type', 'drift_generate_findings')
->where('context->scope_key', $scopeKey) ->where('context->scope_key', $scopeKey)
->where('context->baseline_run_id', (int) $baseline->getKey()) ->where('context->baseline_operation_run_id', (int) $baseline->getKey())
->where('context->current_run_id', (int) $current->getKey()) ->where('context->current_operation_run_id', (int) $current->getKey())
->latest('id') ->latest('id')
->first(); ->first();
@ -120,8 +133,8 @@ public function mount(): void
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey()) ->where('baseline_operation_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey()) ->where('current_operation_run_id', $current->getKey())
->exists(); ->exists();
if ($exists) { if ($exists) {
@ -130,8 +143,8 @@ public function mount(): void
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey()) ->where('baseline_operation_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey()) ->where('current_operation_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW) ->where('status', Finding::STATUS_NEW)
->count(); ->count();
@ -189,8 +202,8 @@ public function mount(): void
$selection = app(BulkSelectionIdentity::class); $selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromQuery([ $selectionIdentity = $selection->fromQuery([
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_operation_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_operation_run_id' => (int) $current->getKey(),
]); ]);
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
@ -198,7 +211,7 @@ public function mount(): void
$opRun = $opService->enqueueBulkOperation( $opRun = $opService->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'drift.generate', type: 'drift_generate_findings',
targetScope: [ targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
], ],
@ -216,8 +229,8 @@ public function mount(): void
initiator: $user, initiator: $user,
extraContext: [ extraContext: [
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_operation_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_operation_run_id' => (int) $current->getKey(),
], ],
emitQueuedNotification: false, emitQueuedNotification: false,
); );
@ -226,10 +239,8 @@ public function mount(): void
$this->state = 'generating'; $this->state = 'generating';
if (! $opRun->wasRecentlyCreated) { if (! $opRun->wasRecentlyCreated) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Drift generation already active') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -261,7 +272,7 @@ public function getBaselineRunUrl(): ?string
return null; return null;
} }
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current()); return route('admin.operations.view', ['run' => $this->baselineRunId]);
} }
public function getCurrentRunUrl(): ?string public function getCurrentRunUrl(): ?string
@ -270,7 +281,7 @@ public function getCurrentRunUrl(): ?string
return null; return null;
} }
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current()); return route('admin.operations.view', ['run' => $this->currentRunId]);
} }
public function getOperationRunUrl(): ?string public function getOperationRunUrl(): ?string

View File

@ -4,7 +4,11 @@
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver; use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -24,6 +28,22 @@ class InventoryCoverage extends Page
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
public static function canAccess(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [

View File

@ -1,38 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class InventoryLanding extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Overview';
protected static ?string $cluster = InventoryCluster::class;
protected string $view = 'filament.pages.inventory-landing';
public function mount(): void
{
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
}
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
}

View File

@ -4,25 +4,84 @@
namespace App\Filament\Pages\Monitoring; namespace App\Filament\Pages\Monitoring;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page; use Filament\Pages\Page;
use UnitEnum; use UnitEnum;
class Alerts extends Page class Alerts extends Page
{ {
protected static bool $isDiscovered = false; protected static ?string $cluster = AlertsCluster::class;
protected static bool $shouldRegisterNavigation = false; protected static ?int $navigationSort = 20;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alerts'; protected static ?string $navigationLabel = 'Overview';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static ?string $slug = 'alerts'; protected static ?string $slug = 'overview';
protected static ?string $title = 'Alerts'; protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts'; protected string $view = 'filament.pages.monitoring.alerts';
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
protected function getHeaderWidgets(): array
{
return [
AlertsKpiHeader::class,
];
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
} }

View File

@ -4,7 +4,9 @@
namespace App\Filament\Pages\Monitoring; namespace App\Filament\Pages\Monitoring;
use App\Support\OperateHub\OperateHubShell;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page; use Filament\Pages\Page;
use UnitEnum; use UnitEnum;
@ -25,4 +27,15 @@ class AuditLog extends Page
protected static ?string $title = 'Audit Log'; protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log'; protected string $view = 'filament.pages.monitoring.audit-log';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_audit_log',
returnActionName: 'operate_hub_return_audit_log',
);
}
} }

View File

@ -7,10 +7,14 @@
use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader; use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
]; ];
} }
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$actions = [
Action::make('operate_hub_scope_operations')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_tenants')
->label('Show all tenants')
->color('gray')
->action(function (): void {
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilter('tenant_id');
$this->redirect('/admin/operations');
});
}
return $actions;
}
public function updatedActiveTab(): void public function updatedActiveTab(): void
{ {
$this->resetPage(); $this->resetPage();
@ -61,6 +105,8 @@ public function table(Table $table): Table
->query(function (): Builder { ->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$query = OperationRun::query() $query = OperationRun::query()
->with('user') ->with('user')
->latest('id') ->latest('id')
@ -71,6 +117,10 @@ public function table(Table $table): Table
->when( ->when(
! $workspaceId, ! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'), fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
); );
return $this->applyActiveTab($query); return $this->applyActiveTab($query);

View File

@ -8,18 +8,21 @@
use App\Models\OperationRun; use App\Models\OperationRun;
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\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class TenantlessOperationRunViewer extends Page class TenantlessOperationRunViewer extends Page
{ {
use AuthorizesRequests;
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
@ -37,15 +40,41 @@ class TenantlessOperationRunViewer extends Page
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$operateHubShell = app(OperateHubShell::class);
$actions = [ $actions = [
Action::make('refresh') Action::make('operate_hub_scope_run_detail')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
}
$actions[] = Action::make('refresh')
->label('Refresh') ->label('Refresh')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('gray') ->color('gray')
->url(fn (): string => isset($this->run) ->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()]) ? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index')), : route('admin.operations.index'));
];
if (! isset($this->run)) { if (! isset($this->run)) {
return $actions; return $actions;
@ -87,20 +116,7 @@ public function mount(OperationRun $run): void
abort(403); abort(403);
} }
$workspaceId = (int) ($run->workspace_id ?? 0); $this->authorize('view', $run);
if ($workspaceId <= 0) {
abort(404);
}
$isMember = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isMember) {
abort(404);
}
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']); $this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
} }

View File

@ -0,0 +1,978 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Settings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class WorkspaceSettings extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'settings/workspace';
protected static ?string $title = 'Workspace settings';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 20;
/**
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
*/
private const SETTING_FIELDS = [
'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'],
'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_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;
/**
* @var array<string, mixed>
*/
public array $data = [];
/**
* @var array<string, mixed>
*/
public array $workspaceOverrides = [];
/**
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
*/
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>
*/
protected function getHeaderActions(): array
{
return [
Action::make('save')
->label('Save')
->action(function (): void {
$this->save();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? null
: 'You do not have permission to manage workspace settings.'),
];
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
}
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
$this->redirect('/admin/choose-workspace');
return;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
$this->workspace = $workspace;
$this->authorizeWorkspaceView($user);
$this->loadFormState();
}
public function content(Schema $schema): Schema
{
return $schema
->statePath('data')
->schema([
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
TextInput::make('backup_retention_keep_last_default')
->label('Default retention keep-last')
->placeholder('Unset (uses default)')
->suffix('versions')
->hint('1 365')
->numeric()
->integer()
->minValue(1)
->maxValue(365)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default'))
->hintAction($this->makeResetAction('backup_retention_keep_last_default')),
TextInput::make('backup_retention_min_floor')
->label('Minimum retention floor')
->placeholder('Unset (uses default)')
->suffix('versions')
->hint('1 365')
->numeric()
->integer()
->minValue(1)
->maxValue(365)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor'))
->hintAction($this->makeResetAction('backup_retention_min_floor')),
]),
Section::make('Drift settings')
->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.'))
->schema([
KeyValue::make('drift_severity_mapping')
->label('Severity mapping')
->keyLabel('Finding type')
->valueLabel('Severity')
->keyPlaceholder('e.g. drift')
->valuePlaceholder('critical, high, medium, or low')
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('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')
->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.'))
->schema([
TextInput::make('operations_operation_run_retention_days')
->label('Operation run retention')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('7 3,650')
->numeric()
->integer()
->minValue(7)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
TextInput::make('operations_stuck_run_threshold_minutes')
->label('Stuck run threshold')
->placeholder('Unset (uses default)')
->suffix('minutes')
->hint('0 10,080')
->numeric()
->integer()
->minValue(0)
->maxValue(10080)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes'))
->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')),
]),
]);
}
public function save(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
$this->resetValidation();
$this->composeSlaSubFieldsIntoData();
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
if ($validationErrors !== []) {
throw ValidationException::withMessages($validationErrors);
}
$writer = app(SettingsWriter::class);
$changedSettingsCount = 0;
foreach (self::SETTING_FIELDS as $field => $setting) {
$incomingValue = $normalizedValues[$field] ?? null;
$currentOverride = $this->workspaceOverrideForField($field);
if ($incomingValue === null) {
if ($currentOverride === null) {
continue;
}
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$changedSettingsCount++;
continue;
}
if ($this->valuesEqual($incomingValue, $currentOverride)) {
continue;
}
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
value: $incomingValue,
);
$changedSettingsCount++;
}
$this->loadFormState();
Notification::make()
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
->success()
->send();
}
public function resetSetting(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
$setting = $this->settingForField($field);
if ($this->workspaceOverrideForField($field) === null) {
Notification::make()
->title('Setting already uses default')
->success()
->send();
return;
}
app(SettingsWriter::class)->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$this->loadFormState();
Notification::make()
->title('Workspace setting reset to default')
->success()
->send();
}
private function loadFormState(): void
{
$resolver = app(SettingsResolver::class);
$data = [];
$workspaceOverrides = [];
$resolvedSettings = [];
foreach (self::SETTING_FIELDS as $field => $setting) {
$resolved = $resolver->resolveDetailed(
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$workspaceValue = $resolved['workspace_value'];
$workspaceOverrides[$field] = $workspaceValue;
$resolvedSettings[$field] = [
'source' => $resolved['source'],
'value' => $resolved['value'],
'system_default' => $resolved['system_default'],
];
$data[$field] = $workspaceValue === null
? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null)
: $this->formatValueForInput($field, $workspaceValue);
}
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$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
{
return Action::make('reset_'.$field)
->label('Reset')
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
}
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
}
return null;
});
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null);
if (! $this->hasWorkspaceOverride($field)) {
return sprintf(
'Unset. Effective value: %s (%s).',
$effectiveValue,
$this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
);
}
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>>}
*/
private function normalizedInputValues(): array
{
$normalizedValues = [];
$validationErrors = [];
foreach (self::SETTING_FIELDS as $field => $_setting) {
try {
$normalizedValues[$field] = $this->normalizeFieldInput(
field: $field,
value: $this->data[$field] ?? null,
);
} catch (ValidationException $exception) {
$messages = [];
foreach ($exception->errors() as $errorMessages) {
foreach ((array) $errorMessages as $message) {
$messages[] = (string) $message;
}
}
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 !== []
? $messages
: ['Invalid value.'];
}
}
return [$normalizedValues, $validationErrors];
}
private function normalizeFieldInput(string $field, mixed $value): mixed
{
$setting = $this->settingForField($field);
if ($value === null) {
return null;
}
if (is_string($value) && trim($value) === '') {
return null;
}
if (is_array($value) && $value === []) {
return null;
}
if ($setting['type'] === 'json') {
$value = $this->normalizeJsonInput($value);
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
$value = $this->normalizeKeyValueInput($value);
if ($value === []) {
return null;
}
}
}
$definition = $this->settingDefinition($field);
$validator = Validator::make(
data: ['value' => $value],
rules: ['value' => $definition->rules],
);
if ($validator->fails()) {
throw ValidationException::withMessages($validator->errors()->toArray());
}
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
{
if (is_array($value)) {
return $value;
}
if (! is_string($value)) {
throw ValidationException::withMessages([
'value' => ['The value must be valid JSON.'],
]);
}
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ValidationException::withMessages([
'value' => ['The value must be valid JSON.'],
]);
}
if (! is_array($decoded)) {
throw ValidationException::withMessages([
'value' => ['The value must be a JSON object.'],
]);
}
return $decoded;
}
private function valuesEqual(mixed $left, mixed $right): bool
{
if ($left === null || $right === null) {
return $left === $right;
}
if (is_array($left) && is_array($right)) {
return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right);
}
if (is_numeric($left) && is_numeric($right)) {
return (int) $left === (int) $right;
}
return $left === $right;
}
private function encodeCanonicalArray(array $value): string
{
$encoded = json_encode($this->sortNestedArray($value));
return is_string($encoded) ? $encoded : '';
}
/**
* @param array<mixed> $value
* @return array<mixed>
*/
private function sortNestedArray(array $value): array
{
foreach ($value as $key => $item) {
if (! is_array($item)) {
continue;
}
$value[$key] = $this->sortNestedArray($item);
}
ksort($value);
return $value;
}
private function formatValueForInput(string $field, mixed $value): mixed
{
$setting = $this->settingForField($field);
if ($setting['type'] === 'json') {
if (! is_array($value)) {
return null;
}
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
return $value;
}
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
return is_numeric($value) ? (int) $value : null;
}
private function formatValueForDisplay(string $field, mixed $value): string
{
$setting = $this->settingForField($field);
if ($setting['type'] === 'json') {
if (! is_array($value) || $value === []) {
return '{}';
}
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : '{}';
}
return is_numeric($value) ? (string) (int) $value : 'null';
}
private function sourceLabel(string $source): string
{
return match ($source) {
'workspace_override' => 'workspace override',
'tenant_override' => 'tenant override',
default => 'system default',
};
}
/**
* @return array{domain: string, key: string, type: 'int'|'json'}
*/
private function settingForField(string $field): array
{
if (! isset(self::SETTING_FIELDS[$field])) {
throw ValidationException::withMessages([
'data' => [sprintf('Unknown settings field: %s', $field)],
]);
}
return self::SETTING_FIELDS[$field];
}
private function settingDefinition(string $field): SettingDefinition
{
$setting = $this->settingForField($field);
return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']);
}
private function hasWorkspaceOverride(string $field): bool
{
return $this->workspaceOverrideForField($field) !== null;
}
private function workspaceOverrideForField(string $field): mixed
{
return $this->workspaceOverrides[$field] ?? null;
}
/**
* 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
{
$user = auth()->user();
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $this->workspace)
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
}
private function authorizeWorkspaceView(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
abort(403);
}
}
private function authorizeWorkspaceManage(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
abort(403);
}
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
@ -31,6 +32,7 @@ public function getWidgets(): array
return [ return [
DashboardKpis::class, DashboardKpis::class,
NeedsAttention::class, NeedsAttention::class,
BaselineCompareNow::class,
RecentDriftFindings::class, RecentDriftFindings::class,
RecentOperations::class, RecentOperations::class,
]; ];

View File

@ -5,7 +5,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection; 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;
@ -41,34 +41,28 @@ class TenantRequiredPermissions extends Page
*/ */
public array $viewModel = []; public array $viewModel = [];
public ?Tenant $scopedTenant = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$tenant = static::resolveScopedTenant(); return static::hasScopedTenantAccess(static::resolveScopedTenant());
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
} }
public function currentTenant(): ?Tenant public function currentTenant(): ?Tenant
{ {
return static::resolveScopedTenant(); return $this->scopedTenant;
} }
public function mount(): void public function mount(): void
{ {
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
}
$this->scopedTenant = $tenant;
$queryFeatures = request()->query('features', $this->features); $queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
@ -147,7 +141,7 @@ public function resetFilters(): void
private function refreshViewModel(): void private function refreshViewModel(): void
{ {
$tenant = static::resolveScopedTenant(); $tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$this->viewModel = []; $this->viewModel = [];
@ -174,27 +168,28 @@ private function refreshViewModel(): void
} }
} }
public function reRunVerificationUrl(): ?string public function reRunVerificationUrl(): string
{ {
$tenant = static::resolveScopedTenant(); $tenant = $this->scopedTenant;
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
}
return route('admin.onboarding');
}
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
} }
$connectionId = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('is_default')
->orderByDesc('id')
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
} }
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant protected static function resolveScopedTenant(): ?Tenant
{ {
$routeTenant = request()->route('tenant'); $routeTenant = request()->route('tenant');
@ -209,6 +204,32 @@ protected static function resolveScopedTenant(): ?Tenant
->first(); ->first();
} }
return Tenant::current(); return null;
}
private static function hasScopedTenantAccess(?Tenant $tenant): bool
{
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
$isWorkspaceMember = WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isWorkspaceMember) {
return false;
}
return $user->canAccessTenant($tenant);
} }
} }

View File

@ -33,6 +33,8 @@
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;
@ -75,6 +77,18 @@ 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;
@ -347,7 +361,7 @@ public function content(Schema $schema): Schema
SchemaActions::make([ SchemaActions::make([
Action::make('wizardStartVerification') Action::make('wizardStartVerification')
->label('Start verification') ->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress') ->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
? null ? null
@ -506,6 +520,12 @@ private function resumeLatestOnboardingSessionIfUnambiguous(): void
private function initializeWizardData(): void private function initializeWizardData(): void
{ {
// Ensure all entangled schema state paths exist at render time.
// Livewire v4 can throw when entangling to missing nested array keys.
$this->data['notes'] ??= '';
$this->data['override_blocked'] ??= false;
$this->data['override_reason'] ??= '';
if (! array_key_exists('connection_mode', $this->data)) { if (! array_key_exists('connection_mode', $this->data)) {
$this->data['connection_mode'] = 'existing'; $this->data['connection_mode'] = 'existing';
} }
@ -629,6 +649,10 @@ private function verificationStatus(): string
return 'in_progress'; return 'in_progress';
} }
if ($run->outcome === OperationRunOutcome::Blocked->value) {
return 'blocked';
}
if ($run->outcome === OperationRunOutcome::Succeeded->value) { if ($run->outcome === OperationRunOutcome::Succeeded->value) {
return 'ready'; return 'ready';
} }
@ -658,7 +682,7 @@ private function verificationStatus(): string
continue; continue;
} }
if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) { if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
return 'blocked'; return 'blocked';
} }
} }
@ -1422,6 +1446,8 @@ 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.')
@ -1436,9 +1462,64 @@ public function startVerification(): void
return; return;
} }
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label('View run')
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
Notification::make() Notification::make()
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started') ->title('Verification blocked')
->success() ->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
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')
@ -1536,7 +1617,7 @@ public function startBootstrap(array $operationTypes): void
return; return;
} }
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */ /** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|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())
@ -1560,6 +1641,7 @@ 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);
@ -1598,15 +1680,19 @@ 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.')
@ -1638,10 +1724,27 @@ public function startBootstrap(array $operationTypes): void
$this->onboardingSession->save(); $this->onboardingSession->save();
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Bootstrap started')
->success() foreach ($types as $operationType) {
->send(); $runId = (int) ($bootstrapRuns[$operationType] ?? 0);
$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(
@ -1652,7 +1755,7 @@ private function dispatchBootstrapJob(
OperationRun $run, OperationRun $run,
): void { ): void {
match ($operationType) { match ($operationType) {
'inventory.sync' => ProviderInventorySyncJob::dispatch( 'inventory_sync' => ProviderInventorySyncJob::dispatch(
tenantId: $tenantId, tenantId: $tenantId,
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
@ -1671,7 +1774,7 @@ private function dispatchBootstrapJob(
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return match ($operationType) {
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
default => null, default => null,
}; };

View File

@ -18,12 +18,26 @@ 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

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class AlertDeliveryResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDelivery::class;
protected static ?string $slug = 'alert-deliveries';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 1;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'id';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert deliveries';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertDelivery::class);
}
public static function canView(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDelivery) {
return false;
}
return $user->can('view', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$user = auth()->user();
return parent::getEloquentQuery()
->with(['tenant', 'rule', 'destination'])
->when(
! $user instanceof User,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
! is_int($workspaceId),
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
is_int($workspaceId),
fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId),
)
->when(
$user instanceof User,
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
->orWhereNull('tenant_id');
}),
)
->when(
Filament::getTenant() instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
)
->latest('id');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Delivery')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
TextEntry::make('event_type')
->label('Event')
->badge()
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
TextEntry::make('severity')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
->placeholder('—'),
TextEntry::make('tenant.name')
->label('Tenant'),
TextEntry::make('rule.name')
->label('Rule')
->placeholder('—'),
TextEntry::make('destination.name')
->label('Destination')
->placeholder('—'),
TextEntry::make('attempt_count')
->label('Attempts'),
TextEntry::make('fingerprint_hash')
->label('Fingerprint')
->copyable(),
TextEntry::make('send_after')
->dateTime()
->placeholder('—'),
TextEntry::make('sent_at')
->dateTime()
->placeholder('—'),
TextEntry::make('last_error_code')
->label('Last error code')
->placeholder('—'),
TextEntry::make('last_error_message')
->label('Last error message')
->placeholder('—')
->columnSpanFull(),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2)
->columnSpanFull(),
Section::make('Payload')
->schema([
ViewEntry::make('payload')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->columns([
TextColumn::make('created_at')
->label('Created')
->since(),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
TextColumn::make('event_type')
->label('Event')
->badge(),
TextColumn::make('severity')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
->placeholder('—'),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
TextColumn::make('rule.name')
->label('Rule')
->placeholder('—'),
TextColumn::make('destination.name')
->label('Destination')
->placeholder('—'),
TextColumn::make('attempt_count')
->label('Attempts'),
])
->filters([
SelectFilter::make('status')
->options([
AlertDelivery::STATUS_QUEUED => 'Queued',
AlertDelivery::STATUS_DEFERRED => 'Deferred',
AlertDelivery::STATUS_SENT => 'Sent',
AlertDelivery::STATUS_FAILED => 'Failed',
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
AlertDelivery::STATUS_CANCELED => 'Canceled',
]),
SelectFilter::make('event_type')
->label('Event type')
->options(function (): array {
$options = AlertRuleResource::eventTypeOptions();
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
return $options;
}),
SelectFilter::make('alert_destination_id')
->label('Destination')
->options(function (): array {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}),
])
->actions([
ViewAction::make()->label('View'),
])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertDeliveries::route('/'),
'view' => Pages\ViewAlertDelivery::route('/{record}'),
];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Resources\Pages\ListRecords;
class ListAlertDeliveries extends ListRecords
{
protected static string $resource = AlertDeliveryResource::class;
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAlertDelivery extends ViewRecord
{
protected static string $resource = AlertDeliveryResource::class;
}

View File

@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDestinationResource\Pages;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class AlertDestinationResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDestination::class;
protected static ?string $slug = 'alert-destinations';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 3;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert targets';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertDestination::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertDestination::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('type')
->required()
->options(self::typeOptions())
->native(false)
->live(),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
TextInput::make('teams_webhook_url')
->label('Teams webhook URL')
->placeholder('https://...')
->url()
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
TagsInput::make('email_recipients')
->label('Email recipients')
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
->placeholder('ops@example.com')
->nestedRecursiveRules(['email']),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: static::getUrl('view', ['record' => $record]))
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
TextColumn::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextColumn::make('updated_at')
->since(),
])
->actions([
EditAction::make()
->label('Edit')
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
ActionGroup::make([
Action::make('toggle_enabled')
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('update', $record)) {
throw new AuthorizationException;
}
$enabled = ! (bool) $record->is_enabled;
$record->forceFill([
'is_enabled' => $enabled,
])->save();
$actionId = $enabled
? AuditActionId::AlertDestinationEnabled
: AuditActionId::AlertDestinationDisabled;
self::audit($record, $actionId, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => $enabled,
]);
Notification::make()
->title($enabled ? 'Destination enabled' : 'Destination disabled')
->success()
->send();
}),
Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('delete', $record)) {
throw new AuthorizationException;
}
self::audit($record, AuditActionId::AlertDestinationDeleted, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
]);
$record->delete();
Notification::make()
->title('Destination deleted')
->success()
->send();
}),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateActions([
\Filament\Actions\CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! static::canCreate()),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertDestinations::route('/'),
'create' => Pages\CreateAlertDestination::route('/create'),
'view' => Pages\ViewAlertDestination::route('/{record}'),
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
];
}
/**
* @param array<string, mixed> $data
*/
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
{
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
if ($webhookUrl === '' && $record instanceof AlertDestination) {
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
}
$data['config'] = [
'webhook_url' => $webhookUrl,
];
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::wrap($data['email_recipients'] ?? []);
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
if ($recipients === [] && $record instanceof AlertDestination) {
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
}
$data['config'] = [
'recipients' => array_values(array_unique($recipients)),
];
}
unset($data['teams_webhook_url'], $data['email_recipients']);
return $data;
}
/**
* @return array<string, string>
*/
public static function typeOptions(): array
{
return [
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
AlertDestination::TYPE_EMAIL => 'Email',
];
}
public static function typeLabel(string $type): string
{
return self::typeOptions()[$type] ?? ucfirst($type);
}
/**
* @param array<string, mixed> $data
*/
public static function assertValidConfigPayload(array $data): void
{
$type = (string) ($data['type'] ?? '');
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'teams_webhook_url' => ['The Teams webhook URL is required.'],
]);
}
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::get($config, 'recipients', []);
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
if ($recipients === []) {
throw ValidationException::withMessages([
'email_recipients' => ['At least one recipient is required for email destinations.'],
]);
}
}
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: auth()->user() instanceof User ? auth()->user() : null,
resourceType: 'alert_destination',
resourceId: (string) $record->getKey(),
);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateAlertDestination extends CreateRecord
{
protected static string $resource = AlertDestinationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$data = AlertDestinationResource::normalizePayload($data);
AlertDestinationResource::assertValidConfigPayload($data);
return $data;
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => (bool) $record->is_enabled,
]);
Notification::make()
->title('Destination created')
->success()
->send();
}
}

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Audit\AuditActionId;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditAlertDestination extends EditRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function getSubheading(): ?string
{
if ($this->lastTestStatus === null) {
return null;
}
$label = ucfirst($this->lastTestStatus->status->value);
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
return $timestamp !== null
? "Last test: {$label} ({$timestamp})"
: "Last test: {$label}";
}
protected function mutateFormDataBeforeSave(array $data): array
{
$record = $this->record;
$data = AlertDestinationResource::normalizePayload(
data: $data,
record: $record instanceof AlertDestination ? $record : null,
);
AlertDestinationResource::assertValidConfigPayload($data);
return $data;
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => (bool) $record->is_enabled,
]);
Notification::make()
->title('Destination updated')
->success()
->send();
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
$baseUrl = AlertDeliveryResource::getUrl('index');
$params = http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
return "{$baseUrl}?{$params}";
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertDestinations extends ListRecords
{
protected static string $resource = AlertDestinationResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
];
}
}

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ViewAlertDestination extends ViewRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function infolist(Schema $schema): Schema
{
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
return $schema
->schema([
Section::make('Last test')
->schema([
TextEntry::make('last_test_status')
->label('Status')
->badge()
->state($lastTest->status->value)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
TextEntry::make('last_test_timestamp')
->label('Timestamp')
->state($lastTest->timestamp?->toDateTimeString())
->placeholder('—'),
])
->columns(2),
Section::make('Details')
->schema([
TextEntry::make('name'),
TextEntry::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
TextEntry::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2),
]);
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
}
}

View File

@ -0,0 +1,477 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertRuleResource\Pages;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use UnitEnum;
class AlertRuleResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertRule::class;
protected static ?string $slug = 'alert-rules';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 2;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert rules';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertRule::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertRule::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with('destinations')
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Rule')
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('event_type')
->required()
->options(self::eventTypeOptions())
->native(false),
Select::make('minimum_severity')
->required()
->options(self::severityOptions())
->native(false),
]),
Section::make('Applies to')
->schema([
Select::make('tenant_scope_mode')
->label('Applies to tenants')
->required()
->options([
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
])
->default(AlertRule::TENANT_SCOPE_ALL)
->native(false)
->live()
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
Select::make('tenant_allowlist')
->label('Selected tenants')
->multiple()
->options(self::tenantOptions())
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
->native(false)
->helperText('Only these tenants will trigger this rule.'),
]),
Section::make('Delivery')
->schema([
TextInput::make('cooldown_seconds')
->label('Cooldown (seconds)')
->numeric()
->minValue(0)
->nullable(),
Toggle::make('quiet_hours_enabled')
->label('Enable quiet hours')
->default(false)
->live(),
TextInput::make('quiet_hours_start')
->label('Quiet hours start')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
TextInput::make('quiet_hours_end')
->label('Quiet hours end')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('quiet_hours_timezone')
->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),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('event_type')
->label('Event')
->badge()
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
TextColumn::make('minimum_severity')
->label('Min severity')
->badge()
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
TextColumn::make('destinations_count')
->label('Destinations')
->counts('destinations'),
TextColumn::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
])
->actions([
EditAction::make()
->label('Edit')
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
ActionGroup::make([
Action::make('toggle_enabled')
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
->requiresConfirmation()
->action(function (AlertRule $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('update', $record)) {
throw new AuthorizationException;
}
$enabled = ! (bool) $record->is_enabled;
$record->forceFill([
'is_enabled' => $enabled,
])->save();
$actionId = $enabled
? AuditActionId::AlertRuleEnabled
: AuditActionId::AlertRuleDisabled;
self::audit($record, $actionId, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'is_enabled' => $enabled,
]);
Notification::make()
->title($enabled ? 'Rule enabled' : 'Rule disabled')
->success()
->send();
}),
Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (AlertRule $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('delete', $record)) {
throw new AuthorizationException;
}
self::audit($record, AuditActionId::AlertRuleDeleted, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
]);
$record->delete();
Notification::make()
->title('Rule deleted')
->success()
->send();
}),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertRules::route('/'),
'create' => Pages\CreateAlertRule::route('/create'),
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function normalizePayload(array $data): array
{
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
$tenantAllowlist = [];
}
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
$data['tenant_allowlist'] = $tenantAllowlist;
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
$data['quiet_hours_enabled'] = $quietHoursEnabled;
if (! $quietHoursEnabled) {
$data['quiet_hours_start'] = null;
$data['quiet_hours_end'] = null;
$data['quiet_hours_timezone'] = null;
}
return $data;
}
/**
* @param array<int, int> $destinationIds
*/
public static function syncDestinations(AlertRule $record, array $destinationIds): void
{
$allowedDestinationIds = AlertDestination::query()
->where('workspace_id', (int) $record->workspace_id)
->whereIn('id', $destinationIds)
->pluck('id')
->map(static fn (mixed $value): int => (int) $value)
->all();
$record->destinations()->syncWithPivotValues(
array_values(array_unique($allowedDestinationIds)),
['workspace_id' => (int) $record->workspace_id],
);
}
/**
* @return array<string, string>
*/
public static function eventTypeOptions(): array
{
return [
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
];
}
/**
* @return array<string, string>
*/
public static function severityOptions(): array
{
return [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'critical' => 'Critical',
];
}
public static function eventTypeLabel(string $eventType): string
{
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
}
/**
* @return array<int, string>
*/
private static function destinationOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<int, string>
*/
private static function tenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<string, string>
*/
private static function timezoneOptions(): array
{
$identifiers = \DateTimeZone::listIdentifiers();
sort($identifiers);
return array_combine($identifiers, $identifiers);
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
$actor = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: $actor instanceof User ? $actor : null,
resourceType: 'alert_rule',
resourceId: (string) $record->getKey(),
);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
class CreateAlertRule extends CreateRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
private array $destinationIds = [];
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$this->destinationIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
Arr::wrap($data['destination_ids'] ?? []),
))));
unset($data['destination_ids']);
return AlertRuleResource::normalizePayload($data);
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof AlertRule) {
return;
}
AlertRuleResource::syncDestinations($record, $this->destinationIds);
AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'minimum_severity' => (string) $record->minimum_severity,
'is_enabled' => (bool) $record->is_enabled,
'destination_ids' => $this->destinationIds,
]);
Notification::make()
->title('Rule created')
->success()
->send();
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
class EditAlertRule extends EditRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
private array $destinationIds = [];
protected function mutateFormDataBeforeFill(array $data): array
{
$record = $this->record;
if ($record instanceof AlertRule) {
$data['destination_ids'] = $record->destinations()
->pluck('alert_destinations.id')
->map(static fn (mixed $value): int => (int) $value)
->all();
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
$this->destinationIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
Arr::wrap($data['destination_ids'] ?? []),
))));
unset($data['destination_ids']);
return AlertRuleResource::normalizePayload($data);
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof AlertRule) {
return;
}
AlertRuleResource::syncDestinations($record, $this->destinationIds);
AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'minimum_severity' => (string) $record->minimum_severity,
'is_enabled' => (bool) $record->is_enabled,
'destination_ids' => $this->destinationIds,
]);
Notification::make()
->title('Rule updated')
->success()
->send();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertRules extends ListRecords
{
protected static string $resource = AlertRuleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
];
}
}

View File

@ -4,10 +4,9 @@
use App\Exceptions\InvalidPolicyTypeException; use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Rules\SupportedPolicyTypesRule; use App\Rules\SupportedPolicyTypesRule;
@ -22,18 +21,20 @@
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
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 BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone; use DateTimeZone;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@ -46,18 +47,23 @@
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
class BackupScheduleResource extends Resource class BackupScheduleResource extends Resource
{ {
protected static ?string $model = BackupSchedule::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
@ -166,6 +172,17 @@ public static function canDeleteAny(): bool
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
} }
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -240,6 +257,9 @@ public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort('next_run_at', 'asc') ->defaultSort('next_run_at', 'asc')
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
->columns([ ->columns([
TextColumn::make('is_enabled') TextColumn::make('is_enabled')
->label('Enabled') ->label('Enabled')
@ -280,32 +300,40 @@ public static function table(Table $table): Table
->label('Last run status') ->label('Last run status')
->badge() ->badge()
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return '—'; return '—';
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label;
}) })
->color(function (?string $state): string { ->color(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray'; return 'gray';
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color;
}) })
->icon(function (?string $state): ?string { ->icon(function (?string $state): ?string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return null; return null;
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon;
}) })
->iconColor(function (?string $state): string { ->iconColor(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray'; return 'gray';
} }
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state); $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), }),
@ -335,6 +363,11 @@ public static function table(Table $table): Table
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([
TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived'),
SelectFilter::make('enabled_state') SelectFilter::make('enabled_state')
->label('Enabled') ->label('Enabled')
->options([ ->options([
@ -366,6 +399,7 @@ public static function table(Table $table): Table
->label('Run now') ->label('Run now')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -384,104 +418,38 @@ public static function table(Table $table): Table
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun( $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
inputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
], ],
initiator: $userModel context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'run_now',
],
initiator: $userModel,
); );
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Run already queued')
->body('This schedule already has a queued or running backup.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log( app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual', action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run', resourceType: 'operation_run',
resourceId: (string) $run->id, resourceId: (string) $operationRun->getKey(),
status: 'success', status: 'success',
context: [ context: [
'metadata' => [ 'metadata' => [
'backup_schedule_id' => $record->id, 'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id, 'operation_run_id' => $operationRun->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'run_now', 'trigger' => 'run_now',
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
}); });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -494,6 +462,7 @@ public static function table(Table $table): Table
->send(); ->send();
}) })
) )
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
@ -501,6 +470,7 @@ public static function table(Table $table): Table
->label('Retry') ->label('Retry')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -519,104 +489,38 @@ public static function table(Table $table): Table
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun( $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.retry', type: 'backup_schedule_run',
inputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
], ],
initiator: $userModel context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'retry',
],
initiator: $userModel,
); );
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Retry already queued')
->body('This schedule already has a queued or running retry.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log( app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual', action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run', resourceType: 'operation_run',
resourceId: (string) $run->id, resourceId: (string) $operationRun->getKey(),
status: 'success', status: 'success',
context: [ context: [
'metadata' => [ 'metadata' => [
'backup_schedule_id' => $record->id, 'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id, 'operation_run_id' => $operationRun->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'retry', 'trigger' => 'retry',
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
}); });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -629,6 +533,7 @@ public static function table(Table $table): Table
->send(); ->send();
}) })
) )
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
@ -637,11 +542,141 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
DeleteAction::make() Action::make('archive')
->label('Archive')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
Gate::authorize('delete', $record);
if ($record->trashed()) {
return;
}
$record->delete();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup_schedule.archived',
resourceType: 'backup_schedule',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->getKey(),
'backup_schedule_name' => $record->name,
],
],
);
}
Notification::make()
->title('Backup schedule archived')
->success()
->send();
})
) )
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->destructive()
->apply(),
UiEnforcement::forAction(
Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
Gate::authorize('restore', $record);
if (! $record->trashed()) {
return;
}
$record->restore();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup_schedule.restored',
resourceType: 'backup_schedule',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->getKey(),
'backup_schedule_name' => $record->name,
],
],
);
}
Notification::make()
->title('Backup schedule restored')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(), ->apply(),
])->icon('heroicon-o-ellipsis-vertical'), UiEnforcement::forAction(
Action::make('forceDelete')
->label('Force delete')
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
Gate::authorize('forceDelete', $record);
if (! $record->trashed()) {
return;
}
if ($record->operationRuns()->exists()) {
Notification::make()
->title('Cannot force delete backup schedule')
->body('Backup schedules referenced by historical runs cannot be removed.')
->danger()
->send();
return;
}
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup_schedule.force_deleted',
resourceType: 'backup_schedule',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->getKey(),
'backup_schedule_name' => $record->name,
],
],
);
}
$record->forceDelete();
Notification::make()
->title('Backup schedule permanently deleted')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
@ -674,96 +709,52 @@ public static function table(Table $table): Table
$bulkRun = null; $bulkRun = null;
$createdRunIds = []; $createdOperationRunIds = [];
/** @var BackupSchedule $record */ /** @var BackupSchedule $record */
foreach ($records as $record) { foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun( $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
inputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
], ],
initiator: $user context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'bulk_run_now',
],
initiator: $user,
); );
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { $createdOperationRunIds[] = (int) $operationRun->getKey();
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log( app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual', action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run', resourceType: 'operation_run',
resourceId: (string) $run->id, resourceId: (string) $operationRun->getKey(),
status: 'success', status: 'success',
context: [ context: [
'metadata' => [ 'metadata' => [
'backup_schedule_id' => $record->id, 'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id, 'operation_run_id' => $operationRun->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now', 'trigger' => 'bulk_run_now',
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
}, emitQueuedNotification: false); }, emitQueuedNotification: false);
} }
$notification = Notification::make() $notification = Notification::make()
->title('Runs dispatched') ->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds))); ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
if (count($createdRunIds) === 0) { if (count($createdOperationRunIds) === 0) {
$notification->warning(); $notification->warning();
} else { } else {
$notification->success(); $notification->success();
@ -774,12 +765,12 @@ 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();
if (count($createdRunIds) > 0) { if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
} }
}) })
@ -815,96 +806,52 @@ public static function table(Table $table): Table
$bulkRun = null; $bulkRun = null;
$createdRunIds = []; $createdOperationRunIds = [];
/** @var BackupSchedule $record */ /** @var BackupSchedule $record */
foreach ($records as $record) { foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun( $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.retry', type: 'backup_schedule_run',
inputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
], ],
initiator: $user context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'bulk_retry',
],
initiator: $user,
); );
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { $createdOperationRunIds[] = (int) $operationRun->getKey();
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log( app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual', action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run', resourceType: 'operation_run',
resourceId: (string) $run->id, resourceId: (string) $operationRun->getKey(),
status: 'success', status: 'success',
context: [ context: [
'metadata' => [ 'metadata' => [
'backup_schedule_id' => $record->id, 'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id, 'operation_run_id' => $operationRun->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry', 'trigger' => 'bulk_retry',
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
}, emitQueuedNotification: false); }, emitQueuedNotification: false);
} }
$notification = Notification::make() $notification = Notification::make()
->title('Retries dispatched') ->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds))); ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
if (count($createdRunIds) === 0) { if (count($createdOperationRunIds) === 0) {
$notification->warning(); $notification->warning();
} else { } else {
$notification->success(); $notification->success();
@ -915,24 +862,19 @@ 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();
if (count($createdRunIds) > 0) { if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
} }
}) })
) )
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
UiEnforcement::forBulkAction( ])->label('More'),
DeleteBulkAction::make('bulk_delete')
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(),
]),
]); ]);
} }
@ -946,10 +888,15 @@ public static function getEloquentQuery(): Builder
->orderBy('next_run_at'); ->orderBy('next_run_at');
} }
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return static::getEloquentQuery()->withTrashed();
}
public static function getRelations(): array public static function getRelations(): array
{ {
return [ return [
BackupScheduleRunsRelationManager::class, BackupScheduleOperationRunsRelationManager::class,
]; ];
} }
@ -1119,6 +1066,18 @@ protected static function policyTypeLabelMap(): array
->all(); ->all();
} }
protected static function scheduleStatusToOutcome(?string $status): ?string
{
return match (strtolower(trim((string) $status))) {
'running' => OperationRunOutcome::Pending->value,
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
'skipped' => OperationRunOutcome::Blocked->value,
'failed', 'canceled' => OperationRunOutcome::Failed->value,
default => null,
};
}
protected static function dayOfWeekOptions(): array protected static function dayOfWeekOptions(): array
{ {
return [ return [

View File

@ -4,11 +4,26 @@
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class EditBackupSchedule extends EditRecord class EditBackupSchedule extends EditRecord
{ {
protected static string $resource = BackupScheduleResource::class; protected static string $resource = BackupScheduleResource::class;
protected function resolveRecord(int|string $key): Model
{
$record = BackupScheduleResource::getEloquentQuery()
->withTrashed()
->find($key);
if ($record === null) {
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
}
return $record;
}
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array
{ {
$data = BackupScheduleResource::ensurePolicyTypes($data); $data = BackupScheduleResource::ensurePolicyTypes($data);

View File

@ -12,8 +12,37 @@ class ListBackupSchedules extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [$this->makeHeaderCreateAction()];
Actions\CreateAction::make(), }
];
protected function getTableEmptyStateActions(): array
{
return [$this->makeEmptyStateCreateAction()];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
private function makeHeaderCreateAction(): Actions\CreateAction
{
return $this->makeCreateAction()
->visible(fn (): bool => $this->tableHasRecords());
}
private function makeEmptyStateCreateAction(): Actions\CreateAction
{
return $this->makeCreateAction();
}
private function makeCreateAction(): Actions\CreateAction
{
return Actions\CreateAction::make()
->label('New backup schedule')
->disabled(fn (): bool => ! BackupScheduleResource::canCreate())
->tooltip(fn (): ?string => BackupScheduleResource::canCreate()
? null
: 'You do not have permission to create backup schedules.');
} }
} }

View File

@ -0,0 +1,96 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
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 Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleOperationRunsRelationManager extends RelationManager
{
protected static string $relationship = 'operationRuns';
protected static ?string $title = 'Executions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Enqueued')
->dateTime(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->formatStateUsing([OperationCatalog::class, 'label']),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
Tables\Columns\TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (OperationRun $record): string {
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
$total = (int) ($counts['total'] ?? 0);
$succeeded = (int) ($counts['succeeded'] ?? 0);
$failed = (int) ($counts['failed'] ?? 0);
if ($total === 0 && $succeeded === 0 && $failed === 0) {
return '—';
}
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
}),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(function (OperationRun $record): string {
$tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant);
})
->openUrlInNewTab(true),
])
->bulkActions([]);
}
}

View File

@ -1,107 +0,0 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleRunsRelationManager extends RelationManager
{
protected static string $relationship = 'runs';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
->label('Scheduled for')
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
Tables\Columns\TextColumn::make('duration')
->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string {
if (! $record->started_at || ! $record->finished_at) {
return '—';
}
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
if ($seconds < 60) {
return $seconds.'s';
}
$minutes = intdiv($seconds, 60);
$rem = $seconds % 60;
return sprintf('%dm %ds', $minutes, $rem);
}),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (BackupScheduleRun $record): string {
$summary = is_array($record->summary) ? $record->summary : [];
$total = (int) ($summary['policies_total'] ?? 0);
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
$errors = (int) ($summary['errors_count'] ?? 0);
if ($total === 0 && $backedUp === 0 && $errors === 0) {
return '—';
}
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
}),
Tables\Columns\TextColumn::make('error_code')
->label('Error')
->badge()
->default('—'),
Tables\Columns\TextColumn::make('error_message')
->label('Message')
->default('—')
->limit(80)
->wrap(),
Tables\Columns\TextColumn::make('backup_set_id')
->label('Backup set')
->default('—')
->url(function (BackupScheduleRun $record): ?string {
if (! $record->backup_set_id) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
})
->openUrlInNewTab(true),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->modalHeading('View backup schedule run')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (BackupScheduleRun $record): View {
return view('filament.modals.backup-schedule-run-view', [
'run' => $record,
]);
}),
])
->bulkActions([]);
}
}

View File

@ -36,6 +36,7 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
@ -43,10 +44,28 @@ class BackupSetResource extends Resource
{ {
protected static ?string $model = BackupSet::class; protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -63,6 +82,17 @@ public static function canCreate(): bool
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); && $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
} }
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 form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -452,7 +482,7 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->apply(), ->apply(),
]), ])->label('More'),
]); ]);
} }

View File

@ -4,7 +4,7 @@
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -12,10 +12,32 @@ class ListBackupSets extends ListRecords
{ {
protected static string $resource = BackupSetResource::class; protected static string $resource = BackupSetResource::class;
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return [ return [
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()), $create->visible(fn (): bool => $this->tableHasRecords()),
];
}
protected function getTableEmptyStateActions(): array
{
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return [
$create,
]; ];
} }
} }

View File

@ -18,7 +18,6 @@
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;
@ -105,10 +104,9 @@ 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)) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Removal already queued')
->body('A matching remove operation is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -196,10 +194,9 @@ 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)) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Removal already queued')
->body('A matching remove operation is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->info()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -337,12 +334,15 @@ public function table(Table $table): Table
->hidden(fn (BackupItem $record) => ! $record->policy_id) ->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true), ->openUrlInNewTab(true),
$removeItem, $removeItem,
])->icon('heroicon-o-ellipsis-vertical'), ])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]) ])
->bulkActions([ ->bulkActions([
Actions\BulkActionGroup::make([ Actions\BulkActionGroup::make([
$bulkRemove, $bulkRemove,
]), ])->label('More'),
]); ]);
} }

View File

@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
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\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class BaselineProfileResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineProfile::class;
protected static ?string $slug = 'baseline-profiles';
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baselines';
protected static ?int $navigationSort = 1;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
}
public static function canCreate(): bool
{
return self::hasManageCapability();
}
public static function canEdit(Model $record): bool
{
return self::hasManageCapability();
}
public static function canDelete(Model $record): bool
{
return self::hasManageCapability();
}
public static function canView(Model $record): bool
{
return self::canViewAny();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with(['activeSnapshot', 'createdByUser'])
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Profile')
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
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)
->columnSpanFull(),
Section::make('Scope')
->schema([
Select::make('scope_jsonb.policy_types')
->label('Policy type scope')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.')
->native(false),
])
->columnSpanFull(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Profile')
->schema([
TextEntry::make('name'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
TextEntry::make('version_label')
->label('Version')
->placeholder('—'),
TextEntry::make('description')
->placeholder('No description')
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
TextEntry::make('scope_jsonb.policy_types')
->label('Policy type scope')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::policyTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('All policy types'),
])
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('createdByUser.name')
->label('Created by')
->placeholder('—'),
TextEntry::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot yet'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
$workspace = self::resolveWorkspace();
return $table
->defaultSort('name')
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
->sortable(),
TextColumn::make('version_label')
->label('Version')
->placeholder('—'),
TextColumn::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->actions([
Action::make('view')
->label('View')
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
->icon('heroicon-o-eye'),
ActionGroup::make([
Action::make('edit')
->label('Edit')
->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
->icon('heroicon-o-pencil-square')
->visible(fn (): bool => self::hasManageCapability()),
self::archiveTableAction($workspace),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateHeading('No baseline profiles')
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
->emptyStateActions([
Action::make('create')
->label('Create baseline profile')
->url(fn (): string => static::getUrl('create'))
->icon('heroicon-o-plus')
->visible(fn (): bool => self::hasManageCapability()),
]);
}
public static function getRelations(): array
{
return [
BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBaselineProfiles::route('/'),
'create' => Pages\CreateBaselineProfile::route('/create'),
'view' => Pages\ViewBaselineProfile::route('/{record}'),
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
];
}
/**
* @return array<string, string>
*/
public static function policyTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::all())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
])
->sort()
->all();
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
$actor = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: ['metadata' => $metadata],
actor: $actor instanceof User ? $actor : null,
resourceType: 'baseline_profile',
resourceId: (string) $record->getKey(),
);
}
private static function resolveWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
private static function hasManageCapability(): bool
{
$user = auth()->user();
$workspace = self::resolveWorkspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private static function archiveTableAction(?Workspace $workspace): Action
{
$action = Action::make('archive')
->label('Archive')
->icon('heroicon-o-archive-box')
->color('warning')
->requiresConfirmation()
->modalHeading('Archive baseline profile')
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability())
->action(function (BaselineProfile $record): void {
if (! self::hasManageCapability()) {
throw new AuthorizationException;
}
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
self::audit($record, AuditActionId::BaselineProfileArchived, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
]);
Notification::make()
->title('Baseline profile archived')
->success()
->send();
});
if ($workspace instanceof Workspace) {
$action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->destructive()
->apply();
}
return $action;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateBaselineProfile extends CreateRecord
{
protected static string $resource = BaselineProfileResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$user = auth()->user();
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
return $data;
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof BaselineProfile) {
return;
}
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => (string) $record->status,
]);
Notification::make()
->title('Baseline profile created')
->success()
->send();
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditBaselineProfile extends EditRecord
{
protected static string $resource = BaselineProfileResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
return $data;
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof BaselineProfile) {
return;
}
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => (string) $record->status,
]);
Notification::make()
->title('Baseline profile updated')
->success()
->send();
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBaselineProfiles extends ListRecords
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate())
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
}

View File

@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineCaptureService;
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 Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewBaselineProfile extends ViewRecord
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
$this->captureAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
];
}
private function captureAction(): Action
{
return Action::make('capture')
->label('Capture Snapshot')
->icon('heroicon-o-camera')
->color('primary')
->visible(fn (): bool => $this->hasManageCapability())
->disabled(fn (): bool => ! $this->hasManageCapability())
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
->requiresConfirmation()
->modalHeading('Capture Baseline Snapshot')
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
->form([
Select::make('source_tenant_id')
->label('Source Tenant')
->options(fn (): array => $this->getWorkspaceTenantOptions())
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
if (! $sourceTenant instanceof Tenant) {
Notification::make()
->title('Source tenant not found')
->danger()
->send();
return;
}
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $sourceTenant, $user);
if (! $result['ok']) {
Notification::make()
->title('Cannot start capture')
->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if (! $run instanceof \App\Models\OperationRun) {
Notification::make()
->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();
});
}
/**
* @return array<int, string>
*/
private function getWorkspaceTenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
private function hasManageCapability(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
}

View File

@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class BaselineTenantAssignmentsRelationManager extends RelationManager
{
protected static string $relationship = 'tenantAssignments';
protected static ?string $title = 'Tenant assignments';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
Tables\Columns\TextColumn::make('assignedByUser.name')
->label('Assigned by')
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('Assigned at')
->dateTime()
->sortable(),
])
->headerActions([
$this->assignTenantAction(),
])
->actions([
$this->removeAssignmentAction(),
])
->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([
$this->assignTenantAction(),
]);
}
private function assignTenantAction(): Action
{
return Action::make('assign')
->label('Assign Tenant')
->icon('heroicon-o-plus')
->visible(fn (): bool => $this->hasManageCapability())
->form([
Select::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->getAvailableTenantOptions())
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$tenantId = (int) $data['tenant_id'];
$existing = BaselineTenantAssignment::query()
->where('workspace_id', $profile->workspace_id)
->where('tenant_id', $tenantId)
->first();
if ($existing instanceof BaselineTenantAssignment) {
Notification::make()
->title('Tenant already assigned')
->body('This tenant already has a baseline assignment in this workspace.')
->warning()
->send();
return;
}
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $profile->workspace_id,
'tenant_id' => $tenantId,
'baseline_profile_id' => (int) $profile->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
$this->auditAssignment($profile, $assignment, $user, 'created');
Notification::make()
->title('Tenant assigned')
->success()
->send();
});
}
private function removeAssignmentAction(): Action
{
return Action::make('remove')
->label('Remove')
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (): bool => $this->hasManageCapability())
->requiresConfirmation()
->modalHeading('Remove tenant assignment')
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
->action(function (BaselineTenantAssignment $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$this->auditAssignment($profile, $record, $user, 'removed');
$record->delete();
Notification::make()
->title('Assignment removed')
->success()
->send();
});
}
/**
* @return array<int, string>
*/
private function getAvailableTenantOptions(): array
{
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$assignedTenantIds = BaselineTenantAssignment::query()
->where('workspace_id', $profile->workspace_id)
->pluck('tenant_id')
->all();
$query = Tenant::query()
->where('workspace_id', $profile->workspace_id)
->orderBy('name');
if (! empty($assignedTenantIds)) {
$query->whereNotIn('id', $assignedTenantIds);
}
return $query->pluck('name', 'id')->all();
}
private function auditAssignment(
BaselineProfile $profile,
BaselineTenantAssignment $assignment,
User $user,
string $action,
): void {
$workspace = Workspace::query()->find($profile->workspace_id);
if (! $workspace instanceof Workspace) {
return;
}
$tenant = Tenant::query()->find($assignment->tenant_id);
$auditLogger = app(WorkspaceAuditLogger::class);
$auditLogger->log(
workspace: $workspace,
action: 'baseline.assignment.'.$action,
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'tenant_id' => (int) $assignment->tenant_id,
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
],
actor: $user,
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
);
}
private function hasManageCapability(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
}

View File

@ -3,18 +3,17 @@
namespace App\Filament\Resources\EntraGroupResource\Pages; namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob; use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSelection;
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
@ -24,16 +23,16 @@ class ListEntraGroups extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Action::make('view_group_sync_runs') Action::make('view_operations')
->label('Group Sync Runs') ->label('Operations')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) ->url(fn (): string => OperationRunLinks::index(Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()), ->visible(fn (): bool => (bool) Tenant::current()),
UiEnforcement::forAction( UiEnforcement::forAction(
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('warning') ->color('primary')
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -47,18 +46,20 @@ protected function getHeaderActions(): array
// --- Phase 3: Canonical Operation Run Start --- // --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
inputs: ['selection_key' => $selectionKey], identityInputs: ['selection_key' => $selectionKey],
initiator: $user context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Group sync already active') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -70,55 +71,21 @@ protected function getHeaderActions(): array
} }
// ---------------------------------------------- // ----------------------------------------------
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob( dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey, selectionKey: $selectionKey,
slotKey: null, slotKey: null,
runId: (int) $run->getKey(), runId: null,
operationRun: $opRun operationRun: $opRun
)); ));
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Group sync started') OperationUxPresenter::queuedToast((string) $opRun->type)
->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

@ -1,168 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
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\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
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 UnitEnum;
class EntraGroupSyncRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroupSyncRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Group Sync Runs';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Sync Run')
->schema([
TextEntry::make('initiator.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
TextEntry::make('selection_key')->label('Selection'),
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('error_count')->label('Errors')->numeric(),
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Error Summary')
->schema([
TextEntry::make('error_code')->placeholder('—'),
TextEntry::make('error_category')->placeholder('—'),
ViewEntry::make('error_summary')
->label('Safe error summary')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('initiator.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
Tables\Columns\TextColumn::make('selection_key')
->label('Selection')
->limit(24)
->copyable(),
Tables\Columns\TextColumn::make('slot_key')
->label('Slot')
->placeholder('—')
->limit(16)
->copyable(),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
])
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('initiator')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
];
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroupSyncRuns extends ListRecords
{
protected static string $resource = EntraGroupSyncRunResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
]));
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewEntraGroupSyncRun extends ViewRecord
{
protected static string $resource = EntraGroupSyncRunResource::class;
}

View File

@ -7,18 +7,26 @@
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;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
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 BackedEnum;
use Filament\Actions; use Filament\Actions;
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;
@ -32,15 +40,19 @@
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
{ {
protected static ?string $model = Finding::class; protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Drift'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings'; protected static ?string $navigationLabel = 'Findings';
@ -58,7 +70,7 @@ public static function canViewAny(): bool
return false; return false;
} }
return $user->can(Capabilities::TENANT_VIEW, $tenant); return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
} }
public static function canView(Model $record): bool public static function canView(Model $record): bool
@ -75,7 +87,7 @@ public static function canView(Model $record): bool
return false; return false;
} }
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) { if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) {
return false; return false;
} }
@ -86,6 +98,17 @@ public static function canView(Model $record): bool
return true; return true;
} }
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
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::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row 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.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -115,26 +138,48 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
TextEntry::make('subject_type')->label('Subject type'), TextEntry::make('subject_type')->label('Subject type'),
TextEntry::make('subject_external_id')->label('External ID')->copyable(), TextEntry::make('subject_external_id')->label('External ID')->copyable(),
TextEntry::make('baseline_run_id') TextEntry::make('baseline_operation_run_id')
->label('Baseline run') ->label('Baseline run')
->url(fn (Finding $record): ?string => $record->baseline_run_id ->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current()) ? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
: null) : null)
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('current_run_id') TextEntry::make('current_operation_run_id')
->label('Current run') ->label('Current run')
->url(fn (Finding $record): ?string => $record->current_run_id ->url(fn (Finding $record): ?string => $record->current_operation_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current()) ? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
: null) : null)
->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('')
@ -260,22 +305,65 @@ 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_ACKNOWLEDGED => 'Acknowledged', Finding::STATUS_TRIAGED => 'Triaged',
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',
]) ])
->default(Finding::STATUS_NEW), ->label('Status'),
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',
]) ])
->default(Finding::FINDING_TYPE_DRIFT), ->label('Type'),
Tables\Filters\Filter::make('scope_key') Tables\Filters\Filter::make('scope_key')
->form([ ->form([
TextInput::make('scope_key') TextInput::make('scope_key')
@ -295,78 +383,45 @@ public static function table(Table $table): Table
Tables\Filters\Filter::make('run_ids') Tables\Filters\Filter::make('run_ids')
->label('Run IDs') ->label('Run IDs')
->form([ ->form([
TextInput::make('baseline_run_id') TextInput::make('baseline_operation_run_id')
->label('Baseline run id') ->label('Baseline run id')
->numeric(), ->numeric(),
TextInput::make('current_run_id') TextInput::make('current_operation_run_id')
->label('Current run id') ->label('Current run id')
->numeric(), ->numeric(),
]) ])
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
$baselineRunId = $data['baseline_run_id'] ?? null; $baselineRunId = $data['baseline_operation_run_id'] ?? null;
if (is_numeric($baselineRunId)) { if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId); $query->where('baseline_operation_run_id', (int) $baselineRunId);
} }
$currentRunId = $data['current_run_id'] ?? null; $currentRunId = $data['current_operation_run_id'] ?? null;
if (is_numeric($currentRunId)) { if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId); $query->where('current_operation_run_id', (int) $currentRunId);
} }
return $query; return $query;
}), }),
]) ])
->actions([ ->actions([
Actions\Action::make('acknowledge')
->label('Acknowledge')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
->authorize(function (Finding $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('update', $record);
})
->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();
}),
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\ActionGroup::make([
...static::workflowActions(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('acknowledge_selected') BulkAction::make('triage_selected')
->label('Acknowledge selected') ->label('Triage selected')
->icon('heroicon-o-check') ->icon('heroicon-o-check')
->color('gray') ->color('gray')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records): void { ->action(function (Collection $records, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
$user = auth()->user(); $user = auth()->user();
@ -374,8 +429,9 @@ public static function table(Table $table): Table
return; return;
} }
$acknowledgedCount = 0; $triagedCount = 0;
$skippedCount = 0; $skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) { foreach ($records as $record) {
if (! $record instanceof Finding) { if (! $record instanceof Finding) {
@ -390,33 +446,346 @@ public static function table(Table $table): Table
continue; continue;
} }
if ($record->status !== Finding::STATUS_NEW) { if (! in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
$record->acknowledge($user); try {
$acknowledgedCount++; $workflow->triage($record, $tenant, $user);
$triagedCount++;
} catch (Throwable) {
$failedCount++;
}
} }
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; $body = "Triaged {$triagedCount} finding".($triagedCount === 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 acknowledge completed') ->title('Bulk triage completed')
->body($body) ->body($body)
->success() ->status($failedCount > 0 ? 'warning' : 'success')
->send(); ->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
) )
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(), ->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)
->apply(),
])->label('More'),
]); ]);
} }
@ -425,6 +794,7 @@ 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')
@ -442,4 +812,300 @@ 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,8 +3,16 @@
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;
@ -13,6 +21,7 @@
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
{ {
@ -20,10 +29,89 @@ class ListFindings extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
UiEnforcement::forAction(
Actions\Action::make('acknowledge_all_matching') if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
->label('Acknowledge all matching') $actions[] = UiEnforcement::forAction(
Actions\Action::make('backfill_lifecycle')
->label('Backfill findings lifecycle')
->icon('heroicon-o-wrench-screwdriver')
->color('gray')
->requiresConfirmation()
->modalHeading('Backfill findings lifecycle')
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
->action(function (OperationRunService $operationRuns): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = \Filament\Facades\Filament::getTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$opRun = $operationRuns->ensureRunWithIdentity(
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();
return;
}
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
BackfillFindingLifecycleJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->body('The backfill will run in the background. You can continue working while it completes.')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
$actions[] = UiEnforcement::forAction(
Actions\Action::make('triage_all_matching')
->label('Triage all matching')
->icon('heroicon-o-check') ->icon('heroicon-o-check')
->color('gray') ->color('gray')
->requiresConfirmation() ->requiresConfirmation()
@ -31,7 +119,7 @@ protected function getHeaderActions(): array
->modalDescription(function (): string { ->modalDescription(function (): string {
$count = $this->getAllMatchingCount(); $count = $this->getAllMatchingCount();
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
}) })
->form(function (): array { ->form(function (): array {
$count = $this->getAllMatchingCount(); $count = $this->getAllMatchingCount();
@ -42,49 +130,94 @@ protected function getHeaderActions(): array
return [ return [
TextInput::make('confirmation') TextInput::make('confirmation')
->label('Type ACKNOWLEDGE to confirm') ->label('Type TRIAGE to confirm')
->required() ->required()
->in(['ACKNOWLEDGE']) ->in(['TRIAGE'])
->validationMessages([ ->validationMessages([
'in' => 'Please type ACKNOWLEDGE to confirm.', 'in' => 'Please type TRIAGE to confirm.',
]), ]),
]; ];
}) })
->action(function (array $data): void { ->action(function (FindingWorkflowService $workflow): void {
$query = $this->buildAllMatchingQuery(); $query = $this->buildAllMatchingQuery();
$count = (clone $query)->count(); $count = (clone $query)->count();
if ($count === 0) { if ($count === 0) {
Notification::make() Notification::make()
->title('No matching findings') ->title('No matching findings')
->body('There are no new findings matching the current filters.') ->body('There are no new findings matching the current filters to triage.')
->warning() ->warning()
->send(); ->send();
return; return;
} }
$updated = $query->update([ $user = auth()->user();
'status' => Finding::STATUS_ACKNOWLEDGED, $tenant = \Filament\Facades\Filament::getTenant();
'acknowledged_at' => now(),
'acknowledged_by_user_id' => auth()->id(), 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->deselectAllTableRecords();
$this->resetPage(); $this->resetPage();
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make() Notification::make()
->title('Bulk acknowledge completed') ->title('Bulk triage completed')
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') ->body($body)
->success() ->status($failedCount > 0 ? 'warning' : 'success')
->send(); ->send();
}) })
) )
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(), ->apply();
];
return $actions;
} }
protected function buildAllMatchingQuery(): Builder protected function buildAllMatchingQuery(): Builder
@ -106,6 +239,27 @@ 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 !== '') {
@ -113,19 +267,36 @@ protected function buildAllMatchingQuery(): Builder
} }
$runIdsState = $this->getTableFilterState('run_ids') ?? []; $runIdsState = $this->getTableFilterState('run_ids') ?? [];
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id'); $baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
if (is_numeric($baselineRunId)) { if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId); $query->where('baseline_operation_run_id', (int) $baselineRunId);
} }
$currentRunId = Arr::get($runIdsState, 'current_run_id'); $currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
if (is_numeric($currentRunId)) { if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId); $query->where('current_operation_run_id', (int) $currentRunId);
} }
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();
@ -141,13 +312,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
: Finding::FINDING_TYPE_DRIFT; : null;
} }
} }

View File

@ -3,9 +3,20 @@
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

@ -37,6 +37,8 @@ class InventoryItemResource extends Resource
{ {
protected static ?string $model = InventoryItem::class; protected static ?string $model = InventoryItem::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static ?string $cluster = InventoryCluster::class; protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 1; protected static ?int $navigationSort = 1;
@ -125,14 +127,15 @@ public static function infolist(Schema $schema): Schema
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
TextEntry::make('external_id')->label('External ID'), TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(), TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
TextEntry::make('last_seen_run_id') TextEntry::make('last_seen_operation_run_id')
->label('Last sync run') ->label('Last inventory sync')
->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id))
->url(function (InventoryItem $record): ?string { ->url(function (InventoryItem $record): ?string {
if (! $record->last_seen_run_id) { if (! $record->last_seen_operation_run_id) {
return null; return null;
} }
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current()); return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
}) })
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('support_restore') TextEntry::make('support_restore')
@ -233,7 +236,7 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('last_seen_at') Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen') ->label('Last seen')
->since(), ->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status') Tables\Columns\TextColumn::make('lastSeenRun.outcome')
->label('Run') ->label('Run')
->badge() ->badge()
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
@ -241,28 +244,28 @@ public static function table(Table $table): Table
return '—'; return '—';
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label;
}) })
->color(function (?string $state): string { ->color(function (?string $state): string {
if (! filled($state)) { if (! filled($state)) {
return 'gray'; return 'gray';
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color;
}) })
->icon(function (?string $state): ?string { ->icon(function (?string $state): ?string {
if (! filled($state)) { if (! filled($state)) {
return null; return null;
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon;
}) })
->iconColor(function (?string $state): ?string { ->iconColor(function (?string $state): ?string {
if (! filled($state)) { if (! filled($state)) {
return 'gray'; return 'gray';
} }
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state); $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), }),

View File

@ -5,7 +5,6 @@
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Jobs\RunInventorySyncJob; use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
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;
@ -45,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('warning') ->color('primary')
->form([ ->form([
Select::make('policy_types') Select::make('policy_types')
->label('Policy types') ->label('Policy types')
@ -152,18 +151,23 @@ protected function getHeaderActions(): array
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'inventory.sync', type: 'inventory_sync',
inputs: $computed['selection'], identityInputs: [
initiator: $user 'selection_hash' => $computed['selection_hash'],
],
context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
]),
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make() OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -176,57 +180,26 @@ protected function getHeaderActions(): array
return; return;
} }
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Inventory sync already active')
->body('A matching inventory sync run is already pending or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'inventory.sync.dispatched', action: 'inventory.sync.dispatched',
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'operation_run_id' => (int) $opRun->getKey(),
'selection_hash' => $run->selection_hash, 'selection_hash' => $computed['selection_hash'],
], ],
], ],
actorId: $user->id, actorId: $user->id,
actorEmail: $user->email, actorEmail: $user->email,
actorName: $user->name, actorName: $user->name,
resourceType: 'inventory_sync_run', resourceType: 'operation_run',
resourceId: (string) $run->id, resourceId: (string) $opRun->getKey(),
); );
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $opRun): void {
RunInventorySyncJob::dispatch( RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(), userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
operationRun: $opRun operationRun: $opRun
); );
}); });

View File

@ -1,228 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
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\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
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 UnitEnum;
class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static bool $shouldRegisterNavigation = true;
protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 2;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
return false;
}
if ($record instanceof InventorySyncRun) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function getNavigationLabel(): string
{
return 'Sync History';
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Sync Run')
->schema([
TextEntry::make('user.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('errors_count')->label('Errors')->numeric(),
TextEntry::make('had_errors')
->label('Had errors')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
])
->columns(2)
->columnSpanFull(),
Section::make('Selection Payload')
->schema([
ViewEntry::make('selection_payload')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Error Summary')
->schema([
ViewEntry::make('error_codes')
->label('Error codes')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
->columnSpanFull(),
ViewEntry::make('error_context')
->label('Safe error context')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
Tables\Columns\TextColumn::make('selection_hash')
->label('Selection')
->copyable()
->limit(12),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('items_observed_count')
->label('Observed')
->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')
->label('Upserted')
->numeric(),
Tables\Columns\TextColumn::make('errors_count')
->label('Errors')
->numeric(),
])
->recordUrl(static fn (Model $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with('user')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function getPages(): array
{
return [
'index' => Pages\ListInventorySyncRuns::route('/'),
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
];
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use Filament\Resources\Pages\ListRecords;
class ListInventorySyncRuns extends ListRecords
{
protected static string $resource = InventorySyncRunResource::class;
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewInventorySyncRun extends ViewRecord
{
protected static string $resource = InventorySyncRunResource::class;
}

View File

@ -10,6 +10,7 @@
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -317,6 +318,14 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('tenant_id') Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant') ->label('Tenant')
->options(function (): array { ->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -330,19 +339,19 @@ public static function table(Table $table): Table
->all(); ->all();
}) })
->default(function (): ?string { ->default(function (): ?string {
$tenant = Filament::getTenant(); $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $tenant instanceof Tenant) { if (! $activeTenant instanceof Tenant) {
return null; return null;
} }
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) { if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
return null; return null;
} }
return (string) $tenant->getKey(); return (string) $activeTenant->getKey();
}) })
->searchable(), ->searchable(),
Tables\Filters\SelectFilter::make('type') Tables\Filters\SelectFilter::make('type')

View File

@ -11,6 +11,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
@ -52,10 +53,28 @@ class PolicyResource extends Resource
{ {
protected static ?string $model = Policy::class; protected static ?string $model = Policy::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
@ -452,10 +471,8 @@ 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)) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Policy sync already active') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -584,7 +601,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
@ -624,19 +641,30 @@ public static function table(Table $table): Table
emitQueuedNotification: false, emitQueuedNotification: false,
); );
Notification::make() $runUrl = OperationRunLinks::view($opRun, $tenant);
->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.") ->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(OperationRunLinks::view($opRun, $tenant)), ->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast((string) $opRun->type)
->body("Queued deletion for {$count} policies.")
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
]) ])
->duration(8000)
->sendToDatabase($user)
->send(); ->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
@ -711,18 +739,6 @@ 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)
@ -784,10 +800,8 @@ 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)) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Policy sync already active') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -881,18 +895,6 @@ 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,7 +13,6 @@
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
@ -37,6 +36,9 @@ private function makeSyncAction(): Actions\Action
->label('Sync from Intune') ->label('Sync from Intune')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->requiresConfirmation()
->modalHeading('Sync policies from Intune')
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
->action(function (self $livewire): void { ->action(function (self $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -65,10 +67,8 @@ 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)) {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Policy sync already active') OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->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')
@ -94,7 +94,6 @@ private function makeSyncAction(): Actions\Action
) )
->requireCapability(Capabilities::TENANT_SYNC) ->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.') ->tooltip('You do not have permission to sync policies.')
->destructive()
->apply(); ->apply();
} }
} }

View File

@ -4,10 +4,13 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Jobs\CapturePolicySnapshotJob; use App\Jobs\CapturePolicySnapshotJob;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -23,7 +26,12 @@ class ViewPolicy extends ViewRecord
protected function getActions(): array protected function getActions(): array
{ {
return [ return [$this->makeCaptureSnapshotAction()];
}
private function makeCaptureSnapshotAction(): Action
{
$action = UiEnforcement::forAction(
Action::make('capture_snapshot') Action::make('capture_snapshot')
->label('Capture snapshot') ->label('Capture snapshot')
->requiresConfirmation() ->requiresConfirmation()
@ -39,7 +47,7 @@ protected function getActions(): array
->default(true) ->default(true)
->helperText('Captures policy scope tag IDs.'), ->helperText('Captures policy scope tag IDs.'),
]) ])
->action(function (array $data) { ->action(function (array $data, AuditLogger $auditLogger) {
$policy = $this->record; $policy = $this->record;
$tenant = $policy->tenant; $tenant = $policy->tenant;
@ -61,6 +69,9 @@ protected function getActions(): array
/** @var OperationRunService $runs */ /** @var OperationRunService $runs */
$runs = app(OperationRunService::class); $runs = app(OperationRunService::class);
$includeAssignments = (bool) ($data['include_assignments'] ?? false);
$includeScopeTags = (bool) ($data['include_scope_tags'] ?? false);
$opRun = $runs->enqueueBulkOperation( $opRun = $runs->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'policy.capture_snapshot', type: 'policy.capture_snapshot',
@ -68,13 +79,13 @@ protected function getActions(): array
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
], ],
selectionIdentity: $selectionIdentity, selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void { dispatcher: function ($operationRun) use ($tenant, $policy, $user, $includeAssignments, $includeScopeTags): void {
CapturePolicySnapshotJob::dispatch( CapturePolicySnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(), userId: (int) $user->getKey(),
policyId: (int) $policy->getKey(), policyId: (int) $policy->getKey(),
includeAssignments: (bool) ($data['include_assignments'] ?? false), includeAssignments: $includeAssignments,
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false), includeScopeTags: $includeScopeTags,
createdBy: $user->email ? Str::limit($user->email, 255, '') : null, createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
operationRun: $operationRun, operationRun: $operationRun,
context: [], context: [],
@ -83,8 +94,8 @@ protected function getActions(): array
initiator: $user, initiator: $user,
extraContext: [ extraContext: [
'policy_id' => (int) $policy->getKey(), 'policy_id' => (int) $policy->getKey(),
'include_assignments' => (bool) ($data['include_assignments'] ?? false), 'include_assignments' => $includeAssignments,
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false), 'include_scope_tags' => $includeScopeTags,
], ],
emitQueuedNotification: false, emitQueuedNotification: false,
); );
@ -105,6 +116,26 @@ protected function getActions(): array
return; return;
} }
$auditLogger->log(
tenant: $tenant,
action: 'policy.capture_snapshot_dispatched',
resourceType: 'operation_run',
resourceId: (string) $opRun->getKey(),
status: 'success',
context: [
'metadata' => [
'policy_id' => (int) $policy->getKey(),
'operation_run_id' => (int) $opRun->getKey(),
'include_assignments' => $includeAssignments,
'include_scope_tags' => $includeScopeTags,
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
);
OperationUxPresenter::queuedToast('policy.capture_snapshot') OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
@ -115,7 +146,16 @@ protected function getActions(): array
$this->redirect(OperationRunLinks::view($opRun, $tenant)); $this->redirect(OperationRunLinks::view($opRun, $tenant));
}) })
->color('primary'), ->color('primary')
]; )
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to capture policy snapshots.')
->apply();
if (! $action instanceof Action) {
throw new \RuntimeException('Capture snapshot action must resolve to a Filament action.');
}
return $action;
} }
} }

View File

@ -51,10 +51,28 @@ class PolicyVersionResource extends Resource
{ {
protected static ?string $model = PolicyVersion::class; protected static ?string $model = PolicyVersion::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
@ -285,20 +303,6 @@ 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')
@ -458,20 +462,6 @@ 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')
@ -819,14 +809,17 @@ public static function table(Table $table): Table
return $action; return $action;
})(), })(),
])->icon('heroicon-o-ellipsis-vertical'), ])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
$bulkPruneVersions, $bulkPruneVersions,
$bulkRestoreVersions, $bulkRestoreVersions,
$bulkForceDeleteVersions, $bulkForceDeleteVersions,
]), ])->label('More'),
]); ]);
} }

View File

@ -18,20 +18,32 @@
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\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
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\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class ProviderConnectionResource extends Resource class ProviderConnectionResource extends Resource
@ -42,18 +54,50 @@ class ProviderConnectionResource extends Resource
protected static ?string $model = ProviderConnection::class; protected static ?string $model = ProviderConnection::class;
protected static ?string $slug = 'tenants/{tenant}/provider-connections'; protected static ?string $slug = 'provider-connections';
protected static bool $isGloballySearchable = false; protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static string|UnitEnum|null $navigationGroup = 'Providers'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $navigationLabel = 'Connections'; protected static ?string $navigationLabel = 'Provider Connections';
protected static ?string $recordTitleAttribute = 'display_name'; protected static ?string $recordTitleAttribute = 'display_name';
public static function getNavigationParentItem(): ?string
{
return 'Integrations';
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
}
public static function canCreate(): bool
{
$tenant = static::resolveTenantForCreate();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
}
protected static function hasTenantCapability(string $capability): bool protected static function hasTenantCapability(string $capability): bool
{ {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveScopedTenant();
@ -72,6 +116,12 @@ protected static function hasTenantCapability(string $capability): bool
protected static function resolveScopedTenant(): ?Tenant protected static function resolveScopedTenant(): ?Tenant
{ {
$tenantExternalId = static::resolveRequestedTenantExternalId();
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
return static::resolveTenantByExternalId($tenantExternalId);
}
$routeTenant = request()->route('tenant'); $routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) { if ($routeTenant instanceof Tenant) {
@ -84,12 +134,277 @@ protected static function resolveScopedTenant(): ?Tenant
->first(); ->first();
} }
return Tenant::current(); $recordTenant = static::resolveTenantFromRouteRecord();
if ($recordTenant instanceof Tenant) {
return $recordTenant;
}
$contextTenantExternalId = static::resolveContextTenantExternalId();
if (is_string($contextTenantExternalId) && $contextTenantExternalId !== '') {
return static::resolveTenantByExternalId($contextTenantExternalId);
}
$filamentTenant = Filament::getTenant();
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
}
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
{
if ($record instanceof ProviderConnection) {
$tenant = $record->tenant;
if (! $tenant instanceof Tenant && is_numeric($record->tenant_id)) {
$tenant = Tenant::query()->whereKey((int) $record->tenant_id)->first();
}
if ($tenant instanceof Tenant) {
return $tenant;
}
}
return static::resolveScopedTenant();
}
public static function resolveRequestedTenantExternalId(): ?string
{
$queryTenant = request()->query('tenant_id');
if (is_string($queryTenant) && $queryTenant !== '') {
return $queryTenant;
}
return static::resolveTenantExternalIdFromLivewireRequest();
}
public static function resolveContextTenantExternalId(): ?string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$contextTenantId = app(WorkspaceContext::class)->lastTenantId(request());
if ($workspaceId !== null && $contextTenantId !== null) {
$tenant = Tenant::query()
->whereKey($contextTenantId)
->where('workspace_id', (int) $workspaceId)
->first();
if ($tenant instanceof Tenant) {
return (string) $tenant->external_id;
}
}
$filamentTenant = Filament::getTenant();
if ($filamentTenant instanceof Tenant) {
return (string) $filamentTenant->external_id;
}
return null;
}
public static function resolveTenantForCreate(): ?Tenant
{
$tenantExternalId = static::resolveRequestedTenantExternalId() ?? static::resolveContextTenantExternalId();
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return null;
}
$tenant = static::resolveTenantByExternalId($tenantExternalId);
$user = auth()->user();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! $tenant instanceof Tenant || ! $user instanceof User || $workspaceId === null) {
return null;
}
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
return null;
}
if (! $user->canAccessTenant($tenant)) {
return null;
}
return $tenant;
}
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
{
if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) {
return null;
}
try {
$url = \Livewire\Livewire::originalUrl();
if (is_string($url) && $url !== '') {
$externalId = static::extractTenantExternalIdFromUrl($url);
if (is_string($externalId) && $externalId !== '') {
return $externalId;
}
}
} catch (\Throwable) {
// Ignore and fall back to referer.
}
$referer = request()->headers->get('referer');
if (! is_string($referer) || $referer === '') {
return null;
}
return static::extractTenantExternalIdFromUrl($referer);
}
private static function extractTenantExternalIdFromUrl(string $url): ?string
{
$query = parse_url($url, PHP_URL_QUERY);
if (is_string($query) && $query !== '') {
parse_str($query, $queryParams);
$tenantExternalId = $queryParams['tenant_id'] ?? null;
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
return $tenantExternalId;
}
}
$path = parse_url($url, PHP_URL_PATH);
if (! is_string($path) || $path === '') {
$path = $url;
}
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
return null;
}
return (string) $matches[1];
}
private static function resolveTenantByExternalId(?string $externalId): ?Tenant
{
if (! is_string($externalId) || $externalId === '') {
return null;
}
return Tenant::query()
->where('external_id', $externalId)
->first();
}
private static function resolveTenantFromRouteRecord(): ?Tenant
{
$record = request()->route('record');
if ($record instanceof ProviderConnection) {
return static::resolveTenantForRecord($record);
}
if (! is_numeric($record)) {
return null;
}
$providerConnection = ProviderConnection::query()
->with('tenant')
->whereKey((int) $record)
->first();
if (! $providerConnection instanceof ProviderConnection) {
return null;
}
return static::resolveTenantForRecord($providerConnection);
}
private static function applyMembershipScope(Builder $query): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$user = auth()->user();
if (! is_int($workspaceId)) {
$filamentTenant = Filament::getTenant();
if ($filamentTenant instanceof Tenant) {
$workspaceId = (int) $filamentTenant->workspace_id;
}
}
if (! is_int($workspaceId) || ! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
return $query
->where('provider_connections.workspace_id', $workspaceId)
->whereExists(function ($membershipScope) use ($user, $workspaceId): void {
$membershipScope
->selectRaw('1')
->from('tenants as scoped_tenants')
->join('tenant_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
$join->on('scoped_memberships.tenant_id', '=', 'scoped_tenants.id')
->where('scoped_memberships.user_id', '=', (int) $user->getKey());
})
->whereColumn('scoped_tenants.id', 'provider_connections.tenant_id')
->where('scoped_tenants.workspace_id', '=', $workspaceId);
});
}
/**
* @return array<string, string>
*/
private static function tenantFilterOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$user = auth()->user();
if (! is_int($workspaceId) || ! $user instanceof User) {
return [];
}
return Tenant::query()
->select(['tenants.external_id', 'tenants.name', 'tenants.environment'])
->join('tenant_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
$join->on('filter_memberships.tenant_id', '=', 'tenants.id')
->where('filter_memberships.user_id', '=', (int) $user->getKey());
})
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->mapWithKeys(function (Tenant $tenant): array {
$environment = strtoupper((string) ($tenant->environment ?? ''));
$label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name;
return [(string) $tenant->external_id => $label];
})
->all();
}
private static function sanitizeErrorMessage(?string $value): ?string
{
if (! is_string($value) || trim($value) === '') {
return null;
}
$normalized = preg_replace('/\s+/', ' ', strip_tags($value));
$normalized = is_string($normalized) ? trim($normalized) : '';
if ($normalized === '') {
return null;
}
return Str::limit($normalized, 120);
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
->schema([
Section::make('Connection')
->schema([ ->schema([
TextInput::make('display_name') TextInput::make('display_name')
->label('Display name') ->label('Display name')
@ -106,6 +421,11 @@ public static function form(Schema $schema): Schema
->label('Default connection') ->label('Default connection')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->helperText('Exactly one default connection is required per tenant/provider.'), ->helperText('Exactly one default connection is required per tenant/provider.'),
])
->columns(2)
->columnSpanFull(),
Section::make('Status')
->schema([
TextInput::make('status') TextInput::make('status')
->label('Status') ->label('Status')
->disabled() ->disabled()
@ -114,6 +434,9 @@ public static function form(Schema $schema): Schema
->label('Health') ->label('Health')
->disabled() ->disabled()
->dehydrated(false), ->dehydrated(false),
])
->columns(2)
->columnSpanFull(),
]); ]);
} }
@ -121,19 +444,41 @@ public static function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(function (Builder $query): Builder { ->modifyQueryUsing(function (Builder $query): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $query->with('tenant');
$tenantId = static::resolveScopedTenant()?->getKey();
if ($workspaceId === null) { $tenantExternalId = static::resolveRequestedTenantExternalId();
return $query->whereRaw('1 = 0');
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return $query;
} }
return $query return $query->whereHas('tenant', function (Builder $tenantQuery) use ($tenantExternalId): void {
->where('workspace_id', (int) $workspaceId) $tenantQuery->where('external_id', $tenantExternalId);
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); });
}) })
->defaultSort('display_name') ->defaultSort('display_name')
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
->columns([ ->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')
->description(function (ProviderConnection $record): ?string {
$environment = $record->tenant?->environment;
if (! is_string($environment) || trim($environment) === '') {
return null;
}
return strtoupper($environment);
})
->url(function (ProviderConnection $record): ?string {
$tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) {
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(), Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(), Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
@ -153,8 +498,44 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(), Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
Tables\Columns\TextColumn::make('last_error_reason_code')
->label('Last error reason')
->toggleable(),
Tables\Columns\TextColumn::make('last_error_message')
->label('Last error message')
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
->toggleable(),
]) ])
->filters([ ->filters([
SelectFilter::make('tenant')
->label('Tenant')
->default(static::resolveScopedTenant()?->external_id)
->options(static::tenantFilterOptions())
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($value): void {
$tenantQuery->where('external_id', $value);
});
}),
SelectFilter::make('provider')
->label('Provider')
->options([
'microsoft' => 'Microsoft',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.provider', $value);
}),
SelectFilter::make('status') SelectFilter::make('status')
->label('Status') ->label('Status')
->options([ ->options([
@ -170,7 +551,7 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->where('status', $value); return $query->where('provider_connections.status', $value);
}), }),
SelectFilter::make('health_status') SelectFilter::make('health_status')
->label('Health') ->label('Health')
@ -187,8 +568,11 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->where('health_status', $value); return $query->where('provider_connections.health_status', $value);
}), }),
Filter::make('default_only')
->label('Default only')
->query(fn (Builder $query): Builder => $query->where('provider_connections.is_default', true)),
]) ])
->actions([ ->actions([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
@ -204,8 +588,8 @@ 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): void { ->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -241,10 +625,9 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Run already queued')
->body('A connection check is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -280,10 +663,9 @@ public static function table(Table $table): Table
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Connection check queued')
->body('Health check was queued and will run in the background.') 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')
@ -302,8 +684,8 @@ 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): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -315,7 +697,7 @@ public static function table(Table $table): Table
$result = $gate->start( $result = $gate->start(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'inventory.sync', operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
@ -343,10 +725,9 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Run already queued')
->body('An inventory sync is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -376,10 +757,9 @@ public static function table(Table $table): Table
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.') 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')
@ -398,8 +778,8 @@ 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): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -439,10 +819,9 @@ public static function table(Table $table): Table
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Run already queued')
->body('A compliance snapshot is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
@ -472,10 +851,9 @@ public static function table(Table $table): Table
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.') 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')
@ -493,9 +871,10 @@ public static function table(Table $table): Table
->label('Set as default') ->label('Set as default')
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->color('primary')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -553,8 +932,8 @@ public static function table(Table $table): Table
->required() ->required()
->maxLength(255), ->maxLength(255),
]) ])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void { ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -566,6 +945,29 @@ public static function table(Table $table): Table
clientSecret: (string) $data['client_secret'], clientSecret: (string) $data['client_secret'],
); );
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.credentials_updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'client_id' => (string) $data['client_id'],
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make() Notification::make()
->title('Credentials updated') ->title('Credentials updated')
->success() ->success()
@ -580,24 +982,28 @@ public static function table(Table $table): Table
->label('Enable connection') ->label('Enable connection')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status; $previousStatus = (string) $record->status;
$status = $hadCredentials ? 'connected' : 'error';
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
$record->update([ $record->update([
'status' => $status, 'status' => $status,
'health_status' => 'unknown', 'health_status' => 'unknown',
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => null, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => null, 'last_error_message' => $errorMessage,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -653,7 +1059,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant(); $tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -698,7 +1104,7 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::PROVIDER_MANAGE) ->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(), ->apply(),
]) ])
->label('Actions') ->label('More')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]) ])
@ -707,19 +1113,11 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $query = parent::getEloquentQuery()
$tenantId = static::resolveScopedTenant()?->getKey(); ->with('tenant');
$query = parent::getEloquentQuery(); return static::applyMembershipScope($query)
->latest('provider_connections.id');
if ($workspaceId === null) {
return $query->whereRaw('1 = 0');
}
return $query
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('id');
} }
public static function getPages(): array public static function getPages(): array
@ -727,33 +1125,65 @@ public static function getPages(): array
return [ return [
'index' => Pages\ListProviderConnections::route('/'), 'index' => Pages\ListProviderConnections::route('/'),
'create' => Pages\CreateProviderConnection::route('/create'), 'create' => Pages\CreateProviderConnection::route('/create'),
'view' => Pages\ViewProviderConnection::route('/{record}'),
'edit' => Pages\EditProviderConnection::route('/{record}/edit'), 'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
]; ];
} }
private static function normalizeTenantExternalId(mixed $tenant): ?string
{
if ($tenant instanceof Tenant) {
return (string) $tenant->external_id;
}
if (is_string($tenant) && $tenant !== '') {
return $tenant;
}
if (is_numeric($tenant)) {
$tenantModel = Tenant::query()->whereKey((int) $tenant)->first();
if ($tenantModel instanceof Tenant) {
return (string) $tenantModel->external_id;
}
}
return null;
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{ {
if (! array_key_exists('tenant', $parameters)) {
if ($tenant instanceof Tenant) {
$parameters['tenant'] = $tenant->external_id;
}
$resolvedTenant = static::resolveScopedTenant();
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
$parameters['tenant'] = $resolvedTenant->external_id;
}
}
$panel ??= 'admin'; $panel ??= 'admin';
$tenantExternalId = null;
if (array_key_exists('tenant', $parameters)) { if (array_key_exists('tenant', $parameters)) {
$tenant = null; $tenantExternalId = static::normalizeTenantExternalId($parameters['tenant']);
unset($parameters['tenant']);
} }
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters); if ($tenantExternalId === null && $tenant instanceof Tenant) {
$tenantExternalId = (string) $tenant->external_id;
}
if ($tenantExternalId === null) {
$record = $parameters['record'] ?? null;
if ($record instanceof ProviderConnection) {
$tenantExternalId = static::resolveTenantForRecord($record)?->external_id;
}
}
if ($tenantExternalId === null) {
$tenantExternalId = static::resolveScopedTenant()?->external_id;
}
if (! array_key_exists('tenant_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
$parameters['tenant_id'] = $tenantExternalId;
}
return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters);
} }
} }

View File

@ -84,18 +84,12 @@ protected function afterCreate(): void
private function currentTenant(): ?Tenant private function currentTenant(): ?Tenant
{ {
$tenant = request()->route('tenant'); $tenant = ProviderConnectionResource::resolveTenantForCreate();
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
return $tenant; return $tenant;
} }
if (is_string($tenant) && $tenant !== '') { return null;
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
} }
} }

View File

@ -16,6 +16,8 @@
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;
@ -28,10 +30,47 @@ class EditProviderConnection extends EditRecord
{ {
protected static string $resource = ProviderConnectionResource::class; protected static string $resource = ProviderConnectionResource::class;
public ?string $scopedTenantExternalId = null;
protected bool $shouldMakeDefault = false; protected bool $shouldMakeDefault = false;
protected bool $defaultWasChanged = false; protected bool $defaultWasChanged = false;
public function mount($record): void
{
parent::mount($record);
$recordTenant = $this->record instanceof ProviderConnection
? ProviderConnectionResource::resolveTenantForRecord($this->record)
: null;
if ($recordTenant instanceof Tenant) {
$this->scopedTenantExternalId = (string) $recordTenant->external_id;
return;
}
$tenantIdFromQuery = request()->query('tenant_id');
if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') {
$this->scopedTenantExternalId = $tenantIdFromQuery;
return;
}
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
$this->scopedTenantExternalId = (string) $tenant->external_id;
return;
}
if (is_string($tenant) && $tenant !== '') {
$this->scopedTenantExternalId = $tenant;
}
}
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array
{ {
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
@ -42,9 +81,16 @@ protected function mutateFormDataBeforeSave(array $data): array
protected function afterSave(): void protected function afterSave(): void
{ {
$tenant = $this->currentTenant();
$record = $this->getRecord(); $record = $this->getRecord();
$tenant = $record instanceof ProviderConnection
? ($record->tenant ?? $this->currentTenant())
: $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
}
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at'])); $changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
if ($this->shouldMakeDefault && ! $record->is_default) { if ($this->shouldMakeDefault && ! $record->is_default) {
@ -210,10 +256,9 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Run already queued')
->body('A connection check is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -249,10 +294,9 @@ protected function getHeaderActions(): array
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Connection check queued')
->body('Health check was queued and will run in the background.') OperationUxPresenter::queuedToast((string) $result->run->type)
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -284,7 +328,7 @@ protected function getHeaderActions(): array
->required() ->required()
->maxLength(255), ->maxLength(255),
]) ])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void { ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -297,6 +341,29 @@ protected function getHeaderActions(): array
clientSecret: (string) $data['client_secret'], clientSecret: (string) $data['client_secret'],
); );
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.credentials_updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'client_id' => (string) $data['client_id'],
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make() Notification::make()
->title('Credentials updated') ->title('Credentials updated')
->success() ->success()
@ -313,6 +380,7 @@ protected function getHeaderActions(): array
->label('Set as default') ->label('Set as default')
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->color('primary')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->status !== 'disabled' && $record->status !== 'disabled'
&& ! $record->is_default && ! $record->is_default
@ -397,7 +465,7 @@ protected function getHeaderActions(): array
$result = $gate->start( $result = $gate->start(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'inventory.sync', operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
@ -425,10 +493,9 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Run already queued')
->body('An inventory sync is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -458,10 +525,9 @@ protected function getHeaderActions(): array
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.') OperationUxPresenter::queuedToast((string) $result->run->type)
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -538,10 +604,9 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Run already queued')
->body('A compliance snapshot is already queued or running.') OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -571,10 +636,9 @@ protected function getHeaderActions(): array
return; return;
} }
Notification::make() OpsUxBrowserEvents::dispatchRunEnqueued($this);
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.') OperationUxPresenter::queuedToast((string) $result->run->type)
->success()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View run') ->label('View run')
@ -593,6 +657,7 @@ protected function getHeaderActions(): array
->label('Enable connection') ->label('Enable connection')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -602,15 +667,18 @@ protected function getHeaderActions(): array
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status; $previousStatus = (string) $record->status;
$status = $hadCredentials ? 'connected' : 'needs_consent';
$errorReasonCode = null;
$errorMessage = null;
$record->update([ $record->update([
'status' => $status, 'status' => $status,
'health_status' => 'unknown', 'health_status' => 'unknown',
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => null, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => null, 'last_error_message' => $errorMessage,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -640,8 +708,8 @@ protected function getHeaderActions(): array
if (! $hadCredentials) { if (! $hadCredentials) {
Notification::make() Notification::make()
->title('Connection enabled (credentials missing)') ->title('Connection enabled (needs consent)')
->body('Add credentials before running checks or operations.') ->body('Grant admin consent before running checks or operations.')
->warning() ->warning()
->send(); ->send();
@ -744,7 +812,9 @@ protected function getFormActions(): array
protected function handleRecordUpdate(Model $record, array $data): Model protected function handleRecordUpdate(Model $record, array $data): Model
{ {
$tenant = $this->currentTenant(); $tenant = $record instanceof ProviderConnection
? ($record->tenant ?? $this->currentTenant())
: $this->currentTenant();
$user = auth()->user(); $user = auth()->user();
@ -767,6 +837,20 @@ protected function handleRecordUpdate(Model $record, array $data): Model
private function currentTenant(): ?Tenant private function currentTenant(): ?Tenant
{ {
if (isset($this->record) && $this->record instanceof ProviderConnection) {
$recordTenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if ($recordTenant instanceof Tenant) {
return $recordTenant;
}
}
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
return Tenant::query()
->where('external_id', $this->scopedTenantExternalId)
->first();
}
$tenant = request()->route('tenant'); $tenant = request()->route('tenant');
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
@ -779,6 +863,12 @@ private function currentTenant(): ?Tenant
->first(); ->first();
} }
$tenantFromCreateResolution = ProviderConnectionResource::resolveTenantForCreate();
if ($tenantFromCreateResolution instanceof Tenant) {
return $tenantFromCreateResolution;
}
return Tenant::current(); return Tenant::current();
} }
} }

View File

@ -3,25 +3,240 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages; namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListProviderConnections extends ListRecords class ListProviderConnections extends ListRecords
{ {
protected static string $resource = ProviderConnectionResource::class; protected static string $resource = ProviderConnectionResource::class;
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return [ return [
UiEnforcement::forAction(
Actions\CreateAction::make() Actions\CreateAction::make()
->authorize(fn (): bool => true) ->label('New connection')
) ->url(function (): string {
->requireCapability(Capabilities::PROVIDER_MANAGE) $tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
->tooltip('You do not have permission to create provider connections.')
->apply(), if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return ProviderConnectionResource::getUrl('create');
}
return ProviderConnectionResource::getUrl('create', [
'tenant_id' => $tenantExternalId,
]);
})
->visible(function () use ($resolver): bool {
if (! $this->tableHasRecords()) {
return false;
}
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $user instanceof User) {
return false;
}
return $resolver->isMember($user, $tenant);
})
->disabled(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $user instanceof User) {
return true;
}
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
})
->tooltip(function () use ($resolver): ?string {
$tenant = $this->resolveTenantForCreateAction();
if (! $tenant instanceof Tenant) {
return 'Select a tenant to create provider connections.';
}
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return 'You do not have permission to create provider connections.';
}
return null;
})
->authorize(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $resolver->isMember($user, $tenant);
}),
]; ];
} }
private function makeEmptyStateCreateAction(): Actions\CreateAction
{
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return Actions\CreateAction::make()
->label('New connection')
->url(function (): string {
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return ProviderConnectionResource::getUrl('create');
}
return ProviderConnectionResource::getUrl('create', [
'tenant_id' => $tenantExternalId,
]);
})
->visible(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $user instanceof User) {
return false;
}
return $resolver->isMember($user, $tenant);
})
->disabled(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $user instanceof User) {
return true;
}
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
})
->tooltip(function () use ($resolver): ?string {
$tenant = $this->resolveTenantForCreateAction();
if (! $tenant instanceof Tenant) {
return 'Select a tenant to create provider connections.';
}
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return 'You do not have permission to create provider connections.';
}
return null;
})
->authorize(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $resolver->isMember($user, $tenant);
});
}
private function resolveTenantExternalIdForCreateAction(): ?string
{
$filterValue = data_get($this->getTableFilterState('tenant'), 'value');
if (is_string($filterValue) && $filterValue !== '') {
return $filterValue;
}
$requested = ProviderConnectionResource::resolveRequestedTenantExternalId()
?? ProviderConnectionResource::resolveContextTenantExternalId();
if (is_string($requested) && $requested !== '') {
return $requested;
}
$filamentTenant = Filament::getTenant();
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
}
private function resolveTenantForCreateAction(): ?Tenant
{
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return null;
}
return Tenant::query()
->where('external_id', $tenantExternalId)
->first();
}
public function getTableEmptyStateHeading(): ?string
{
return 'No provider connections found';
}
public function getTableEmptyStateDescription(): ?string
{
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
}
public function getTableEmptyStateActions(): array
{
return [$this->makeEmptyStateCreateAction()];
}
} }

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewProviderConnection extends ViewRecord
{
protected static string $resource = ProviderConnectionResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
];
}
}

View File

@ -2,6 +2,8 @@
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;
@ -36,6 +38,7 @@
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -50,6 +53,7 @@
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;
@ -60,6 +64,17 @@ class RestoreRunResource extends Resource
{ {
protected static ?string $model = RestoreRun::class; protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
@ -759,191 +774,7 @@ public static function table(Table $table): Table
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
ActionGroup::make([ ActionGroup::make([
UiEnforcement::forTableAction( static::rerunActionWithGate(),
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',
);
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
$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')
->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')
@ -1520,6 +1351,37 @@ 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']);
@ -1673,11 +1535,23 @@ 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) {
Notification::make() $existingOpRunId = (int) ($existing->operation_run_id ?? 0);
->title('Restore already queued') $existingOpRun = $existingOpRunId > 0
->body('Reusing the active restore run.') ? \App\Models\OperationRun::query()->find($existingOpRunId)
->info() : null;
->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;
} }
@ -1699,11 +1573,23 @@ 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) {
Notification::make() $existingOpRunId = (int) ($existing->operation_run_id ?? 0);
->title('Restore already queued') $existingOpRun = $existingOpRunId > 0
->body('Reusing the active restore run.') ? \App\Models\OperationRun::query()->find($existingOpRunId)
->info() : null;
->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;
} }
@ -1727,7 +1613,35 @@ public static function createRestoreRun(array $data): RestoreRun
status: 'success', status: 'success',
); );
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); /** @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) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return $restoreRun->refresh(); return $restoreRun->refresh();
} }
@ -1911,4 +1825,343 @@ 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

@ -4,7 +4,7 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -12,10 +12,32 @@ class ListRestoreRuns extends ListRecords
{ {
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return [ return [
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()), $create->visible(fn (): bool => $this->tableHasRecords()),
];
}
protected function getTableEmptyStateActions(): array
{
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return [
$create,
]; ];
} }
} }

View File

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

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

@ -0,0 +1,73 @@
<?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(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -4,16 +4,21 @@
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\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService; use App\Services\OperationRunService;
use App\Services\Intune\TenantConfigService; use App\Services\Verification\StartVerification;
use App\Services\Intune\TenantPermissionService;
use App\Services\Providers\ProviderConnectionResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Providers\ProviderNextStepsRegistry; 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;
@ -23,11 +28,18 @@ 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,
AdminRolesSummaryWidget::class,
]; ];
} }
@ -39,7 +51,7 @@ protected function getHeaderActions(): array
Actions\Action::make('provider_connections') Actions\Action::make('provider_connections')
->label('Provider connections') ->label('Provider connections')
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin')) ->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
) )
->requireCapability(Capabilities::PROVIDER_VIEW) ->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(), ->apply(),
@ -63,60 +75,195 @@ protected function getHeaderActions(): array
->url(fn (Tenant $record) => TenantResource::entraUrl($record)) ->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
->openUrlInNewTab(), ->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('verify') Actions\Action::make('verify')
->label('Verify configuration') ->label('Verify configuration')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function ( ->action(function (
Tenant $record, Tenant $record,
TenantConfigService $configService, StartVerification $verification,
TenantPermissionService $permissionService, ): void {
RbacHealthService $rbacHealthService, $user = auth()->user();
AuditLogger $auditLogger,
ProviderConnectionResolver $connectionResolver,
ProviderNextStepsRegistry $nextStepsRegistry,
) {
$resolution = $connectionResolver->resolveDefault($record, 'microsoft');
if (! $resolution->resolved) { if (! $user instanceof User) {
$reasonCode = $resolution->effectiveReasonCode(); abort(403);
$nextSteps = $nextStepsRegistry->forReason($record, $reasonCode, $resolution->connection); }
$notification = Notification::make() if (! $user->canAccessTenant($record)) {
->title('Verification blocked') abort(404);
->body("Blocked by provider configuration ({$reasonCode}).") }
->warning();
$result = $verification->providerConnectionCheckForTenant(
tenant: $record,
initiator: $user,
extraContext: [
'surface' => [
'kind' => 'tenant_view_header',
],
],
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active run to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) { foreach ($nextSteps as $index => $step) {
if (! is_array($step)) { if (! is_array($step)) {
continue; continue;
} }
$label = is_string($step['label'] ?? null) ? $step['label'] : null; $label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? $step['url'] : null; $url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === null || $url === null) { if ($label === '' || $url === '') {
continue; continue;
} }
$notification->actions([ $actions[] = Actions\Action::make('next_step_'.$index)
Actions\Action::make('next_step_'.$index)
->label($label) ->label($label)
->url($url), ->url($url);
]);
break; break;
} }
$notification->send(); Notification::make()
->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions($actions)
->send();
return; return;
} }
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}), }),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->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

@ -5,6 +5,7 @@
use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
@ -30,6 +31,20 @@ protected function afterCreate(): void
], ],
); );
app(WorkspaceAuditLogger::class)->log(
workspace: $this->record,
action: 'workspace.created',
actor: $user,
resourceType: 'workspace',
resourceId: (string) $this->record->getKey(),
context: [
'metadata' => [
'workspace_id' => (int) $this->record->getKey(),
'slug' => (string) $this->record->slug,
],
],
);
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request()); app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
} }
} }

View File

@ -3,9 +3,30 @@
namespace App\Filament\Resources\Workspaces\Pages; namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditWorkspace extends EditRecord class EditWorkspace extends EditRecord
{ {
protected static string $resource = WorkspaceResource::class; protected static string $resource = WorkspaceResource::class;
protected function afterSave(): void
{
$user = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $this->record,
action: 'workspace.updated',
actor: $user instanceof User ? $user : null,
resourceType: 'workspace',
resourceId: (string) $this->record->getKey(),
context: [
'metadata' => [
'workspace_id' => (int) $this->record->getKey(),
'slug' => (string) $this->record->slug,
],
],
);
}
} }

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