Compare commits

..

61 Commits

Author SHA1 Message Date
439248ba15 feat: verification report framework (074) (#89)
Implements the 074 verification checklist framework.

Highlights:
- Versioned verification report contract stored in operation_runs.context.verification_report (DB-only viewer).
- Strict sanitizer/redaction (evidence pointers only; no tokens/headers/payloads) + schema validation.
- Centralized BADGE-001 semantics for check status, severity, and overall report outcome.
- Deterministic start (dedupe while active) via shared StartVerification service; capability-first authorization (non-member 404, member missing capability 403).
- Completion audit event (verification.completed) with redacted metadata.
- Integrations: OperationRun detail viewer, onboarding wizard verification step, provider connection start surfaces.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Verification tests/Unit/Badges/VerificationBadgesTest.php
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #89
2026-02-03 23:58:17 +00:00
b6343d5c3a feat: unified managed tenant onboarding wizard (#88)
Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).

## 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 <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #88
2026-02-03 17:30:15 +00:00
5f9e6fb04a feat: workspace-first managed tenants + RBAC membership UI fixes (072) (#87)
Implements spec 072 (workspace-first managed tenants enforcement) and follow-up RBAC fixes.

Highlights:
- Workspace-scoped managed tenants landing and enforcement for tenant routes.
- Workspace membership management UI fixed to use workspace capabilities.
- Membership tables now show user email + domain for clearer identification.

Tests:
- Targeted Pest tests for routing/enforcement and RBAC UI enforcement.
- Pint ran on dirty files.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #87
2026-02-02 23:54:22 +00:00
38d9826f5e feat: workspace context enforcement + ownership safeguards (#86)
Implements workspace-first enforcement and UX:
- Workspace selected before tenant flows; /admin routes into choose-workspace/choose-tenant
- Tenant lists and default tenant selection are scoped to current workspace
- Workspaces UI is tenantless at /admin/workspaces

Security hardening:
- Workspaces can never have 0 owners (blocks last-owner removal/demotion)
- Blocked attempts are audited with action_id=workspace_membership.last_owner_blocked + required metadata
- Optional break-glass recovery page to re-assign workspace owner (audited)

Tests:
- Added/updated Pest feature tests covering redirects, scoping, tenantless workspaces, last-owner guards, and break-glass recovery.

Notes:
- Filament v5 strict Page property signatures respected in RepairWorkspaceOwners.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #86
2026-02-02 23:00:56 +00:00
a989ef1a23 feat: workspace context enforcement (specs 070–072) (#85)
Implements specs 070–072 (workspace foundation, workspace-scoped tenant selection, managed-tenants workspace enforcement).

Highlights
- Adds Workspace + WorkspaceMembership models/migrations + middleware to persist/enforce current workspace context.
- Scopes tenant selection to the current workspace.
- Makes legacy `/admin/managed-tenants*` routes redirect into workspace-scoped URLs.
- Enforces tenant routes under `/admin/t/{tenant}` to 404 when workspace context is missing or mismatched.
- Fixes Filament page Blade wrappers so header actions render on choose-workspace / choose-tenant / no-access pages.

Verification
- Pint: `vendor/bin/sail bin pint --dirty`
- Tests: `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php tests/Feature/Workspaces tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php tests/Feature/ManagedTenants tests/Feature/AdminNewRedirectTest.php`

Notes
- Filament v5 / Livewire v4 compatible.
- Panel provider registration stays in `bootstrap/providers.php` (Laravel 11+ rule).
- No new heavy frontend assets added.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #85
2026-02-02 10:07:41 +00:00
3490fb9e2c feat: RBAC troubleshooting & tenant UI bugfix pack (spec 067) (#84)
Summary
Implements Spec 067 “RBAC Troubleshooting & Tenant UI Bugfix Pack v1” for the tenant admin plane (/admin) with strict RBAC UX semantics:

Non-member tenant scope ⇒ 404 (deny-as-not-found)
Member lacking capability ⇒ 403 server-side, while the UI stays visible-but-disabled with standardized tooltips
What changed
Tenant view header actions now use centralized UI enforcement (no “normal click → error page” for readonly members).
Archived tenants remain resolvable in tenant-scoped routes for entitled members; an “Archived” banner is shown.
Adds tenant-scoped diagnostics page (/admin/t/{tenant}/diagnostics) with safe repair actions (confirmation + authorization + audit log).
Adds/updates targeted Pest tests to lock the 404 vs 403 semantics and action UX.
Implementation notes
Livewire v4.0+ compliance: Uses Filament v5 + Livewire v4 conventions; widget Blade views render a single root element.
Provider registration: Laravel 11+ providers stay in providers.php (no changes required).
Global search: No global search behavior/resources changed in this PR.
Destructive actions:
Tenant archive/restore/force delete and diagnostics repairs execute via ->action(...) and include ->requiresConfirmation().
Server-side authorization is enforced (non-members 404, insufficient capability 403).
Assets: No new assets. No change to php artisan filament:assets expectations.
Tests
Ran:

vendor/bin/sail bin pint --dirty
vendor/bin/sail artisan test --compact (focused files for Spec 067)

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #84
2026-01-31 20:09:25 +00:00
d1a9989037 feat/066-rbac-ui-enforcement-helper-v2 (#83)
Implementiert Feature 066: “RBAC UI Enforcement Helper v2” inkl. Migration der betroffenen Filament-Surfaces + Regression-Tests.

Was ist drin

Neuer Helper:
UiEnforcement.php: mixed visibility (preserveVisibility, andVisibleWhen, andHiddenWhen), tenant resolver (tenantFromFilament, tenantFromRecord, tenantFrom(callable)), bulk preflight (preflightByCapability, preflightByTenantMembership, preflightSelection) + server-side authorizeOrAbort() / authorizeBulkSelectionOrAbort().
UiTooltips.php: standard Tooltip “Insufficient permission — ask a tenant Owner.”
Filament migrations (weg von Gate::… / abort_* hin zu UiEnforcement):
Backup/Restore (mixed visibility)
TenantResource (record-scoped tenant actions + bulk preflight)
Inventory/Entra/ProviderConnections (Tier-2 surfaces)
Guardrails:
NoAdHocFilamentAuthPatternsTest.php als CI-failing allowlist guard für app/Filament/**.
Verhalten / Contract

Non-member: deny-as-not-found (404) auf tenant routes; Actions hidden.
Member ohne Capability: Action visible but disabled + standard tooltip; keine Ausführung.
Member mit Capability: Action enabled; destructive/high-impact Actions bleiben confirmation-gated (->requiresConfirmation()).
Server-side Enforcement bleibt vorhanden: Mutations/Operations rufen authorizeOrAbort() / authorizeBulkSelectionOrAbort().
Tests

Neue/erweiterte Feature-Tests für RBAC UX inkl. Http::preventStrayRequests() (DB-only render):
BackupSetUiEnforcementTest.php
RestoreRunUiEnforcementTest.php
ProviderConnectionsUiEnforcementTest.php
diverse bestehende Filament Tests erweitert (Inventory/Entra/Tenant actions/bulk)
Unit-Tests:
UiEnforcementTest.php
UiEnforcementBulkPreflightQueryCountTest.php
Verification

vendor/bin/sail bin pint --dirty 
vendor/bin/sail artisan test --compact tests/Unit/Auth tests/Feature/Filament tests/Feature/Guards tests/Feature/Rbac  (185 passed, 5 skipped)
Notes für Reviewer

Filament v5 / Livewire v4 compliant.
Destructive actions: weiterhin ->requiresConfirmation() + server-side auth.
Bulk: authorization preflight ist set-based (Query-count test vorhanden).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #83
2026-01-30 17:28:47 +00:00
7217559e5a spec/066-rbac-ui-enforcement-helper-v2 (#82)
Ziel: Spec/Plan/Tasks für “RBAC UI Enforcement Helper v2” (suite-wide, mixed visibility, record-scoped tenant) bereitstellen, damit die anschließende Implementierung sauber reviewbar ist.

Enthält

Feature-Spec inkl. RBAC-UX Contract (Non-member 404/hidden, member-no-cap disabled + Tooltip, member-with-cap enabled).
Implementation Plan + Research/Decisions.
Contracts:
UiEnforcement v2 (mixed visibility composition, tenant resolvers, bulk preflight).
Guardrails (CI-failing allowlist guard gegen ad-hoc Filament auth patterns).
Data-model/Quickstart/Tasks inkl. “Definition of Done”.
Review-Fokus

Scope: Tenant plane only (/admin/t/{tenant}), Platform plane out of scope.
Bulk semantics: authorization-only all-or-nothing; eligibility separat mit Feedback.
preserveVisibility() nur tenant-scoped, verboten für record-scoped/cross-tenant.
Standard tooltip copy: “Insufficient permission — ask a tenant Owner.”
Keine Code-Änderungen

PR ist spec-only (keine Runtime-Änderungen).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #82
2026-01-30 17:22:25 +00:00
6a86c5901a 066-rbac-ui-enforcement-helper (#81)
Kontext / Ziel
Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk).

Links zur Spec:

spec.md
plan.md
quickstart.md
Was ist drin
Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions)
Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext)
Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns)
CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**:
verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless
Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort)
RBAC-UX Semantik (konsequent & testbar)
Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards.
Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects).
Member mit Capability: Action enabled und ausführbar.
Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)).
Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes.

Sicherheit / Scope
Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP).
Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership).
Kein Asset-Setup erforderlich; keine neuen Filament Assets.
Compliance Notes (Repo-Regeln)
Filament v5 / Livewire v4.0+ kompatibel.
Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert).
Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR.
Tests / Qualität
Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard.
Guard-Test: “No ad-hoc Filament auth patterns”.
Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped.
Checklist: requirements.md vollständig (16/16).
Review-Fokus
API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply()
Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #81
2026-01-30 16:58:02 +00:00
cfbc74c035 docs: consolidate RBAC-UX standards into constitution v1.6.0 (#80)
## 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 <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #80
2026-01-28 22:04:17 +00:00
d90fb0f963 065-tenant-rbac-v1 (#79)
PR Body
Implements Spec 065 “Tenant RBAC v1” with capabilities-first RBAC, tenant membership scoping (Option 3), and consistent Filament action semantics.

Key decisions / rules

Tenancy Option 3: tenant switching is tenantless (ChooseTenant), tenant-scoped routes stay scoped, non-members get 404 (not 403).
RBAC model: canonical capability registry + role→capability map + Gates for each capability (no role-string checks in UI logic).
UX policy: for tenant members lacking permission → actions are visible but disabled + tooltip (avoid click→403).
Security still enforced server-side.
What’s included

Capabilities foundation:
Central capability registry (Capabilities::*)
Role→capability mapping (RoleCapabilityMap)
Gate registration + resolver/manager updates to support tenant-scoped authorization
Filament enforcement hardening across the app:
Tenant registration & tenant CRUD properly gated
Backup/restore/policy flows aligned to “visible-but-disabled” where applicable
Provider operations (health check / inventory sync / compliance snapshot) guarded and normalized
Directory groups + inventory sync start surfaces normalized
Policy version maintenance actions (archive/restore/prune/force delete) gated
SpecKit artifacts for 065:
spec.md, plan/tasks updates, checklists, enforcement hitlist
Security guarantees

Non-member → 404 via tenant scoping/membership guards.
Member without capability → 403 on execution, even if UI is disabled.
No destructive actions execute without proper authorization checks.
Tests

Adds/updates Pest coverage for:
Tenant scoping & membership denial behavior
Role matrix expectations (owner/manager/operator/readonly)
Filament surface checks (visible/disabled actions, no side effects)
Provider/Inventory/Groups run-start authorization
Verified locally with targeted vendor/bin/sail artisan test --compact …
Deployment / ops notes

No new services required.
Safe change: behavior is authorization + UI semantics; no breaking route changes intended.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #79
2026-01-28 21:09:47 +00:00
3a3de045ba docs: enforce RBAC constitution gates in spec templates (#78)
## 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 <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #78
2026-01-27 22:09:56 +00:00
210cf5ce8b feat: implement auth structure system panel (#77)
Implements 064-auth-structure (Auth Structure v1.0):

Adds platform_users + PlatformUser identity (factory + seeder) for platform operators
Introduces platform auth guard/provider in auth.php
Adds a dedicated Filament v5 System panel at system using guard platform (custom login + dashboard)
Enforces strict cross-scope isolation between /admin and system (deny-as-404)
Adds platform capability gating (platform.access_system_panel, platform.use_break_glass) + gates in AuthServiceProvider
Implements audited break-glass mode (enter/exit/expire), banner via render hook, feature flag + TTL config
Removes legacy users.is_platform_superadmin runtime usage and adds an architecture test to prevent regressions
Updates tenant membership pivot usage where needed (tenant_memberships)
Testing:

vendor/bin/sail artisan test --compact tests/Feature/Auth (28 passed)
vendor/bin/sail bin pint --dirty
Notes:

Filament v5 / Livewire v4 compatible.
Panel providers registered in providers.php.
Destructive actions use ->action(...) + ->requiresConfirmation() where applicable.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #77
2026-01-27 21:49:18 +00:00
c5fbcaa692 063-entra-signin (#76)
Key changes

Adds Entra OIDC redirect + callback endpoints under /auth/entra/* (token exchange only there).
Upserts tenant users keyed by (entra_tenant_id = tid, entra_object_id = oid); regenerates session; never stores tokens.
Blocks disabled / soft-deleted users with a generic error and safe logging.
Membership-based post-login routing:
0 memberships → /admin/no-access
1 membership → tenant dashboard (via Filament URL helpers)
>1 memberships → /admin/choose-tenant
Adds Filament pages:
/admin/choose-tenant (tenant selection + redirect)
/admin/no-access (tenantless-safe)
Both use simple layout to avoid tenant-required UI.
Guards / tests

Adds DbOnlyPagesDoNotMakeHttpRequestsTest to enforce DB-only render/hydration for:
/admin/login, /admin/no-access, /admin/choose-tenant
with Http::preventStrayRequests()
Adds session separation smoke coverage to ensure tenant session doesn’t access system and vice versa.
Runs: vendor/bin/sail artisan test --compact tests/Feature/Auth

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #76
2026-01-27 16:38:53 +00:00
81c010fa00 fix: Harden SyncPoliciesJob supported types handling (#75)
Harden SyncPoliciesJob type input parsing + fail fast when supported types are empty/mismatched. Pass supported policy types from Tenant sync action and add regression tests.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #75
2026-01-26 19:23:40 +00:00
eef85af990 062-tenant-rbac-v1 (#74)
Kurzbeschreibung

Implementiert Tenant RBAC v1 (specs/062-tenant-rbac-v1): tenant_memberships, Capability registry/resolver, gates, Filament RelationManager für Tenant→Members, Last‑Owner‑Guard, bootstrap assign/recover (break‑glass), Audit-Logging.
Wichtige Änderungen

Migration: create_tenant_memberships_table (T004) — ausgeführt
Models/Services: TenantMembership, Capabilities, RoleCapabilityMap, CapabilityResolver (T008–T013)
Auth: Gates registriert in AuthServiceProvider.php (T011)
Filament: RelationManager unter Settings → Tenants (Members CRUD + Last‑Owner‑Guard) (T017–T018)
Break‑glass: lokale platform superadmin + persistent banner + bootstrap_recover action (T024–T026)
Audit: Audit‑Einträge für membership actions mit canonical action_ids (T022)
Tests: neue/aktualisierte Feature- und Unit‑Tests (siehe Test‑Abschnitt)
Migrations / Deploy

Run migrations: vendor/bin/sail artisan migrate
Keine neuen Panel‑Assets registriert (kein php artisan filament:assets nötig)
Wenn Frontend nicht sichtbar: vendor/bin/sail npm run dev oder vendor/bin/sail npm run build
Tests (geprüft / neu)

Fokus-Suite ausgeführt für Tenant RBAC (T031).
Neu / aktualisiert:
CapabilitiesRegistryTest
CapabilityResolverTest
TenantSwitcherScopeTest
TenantRouteDenyAsNotFoundTest
TenantMembershipCrudTest
LastOwnerGuardTest
TenantBootstrapAssignTest
MembershipAuditLogTest
BreakGlassRecoveryTest
Befehl zum lokalen Ausführen (minimal): vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure
Filament / Sicherheits‑Contract (erforderliche Punkte)

Livewire v4.0+ compliance: bestätigt (Filament v5 target).
Provider registration: keine neue Panel‑Provider-Änderung; falls nötig: providers.php (Laravel 11+).
Globale Suche: keine neuen Ressourcen für Global Search hinzugefügt; vorhandene Ressourcen behalten Edit/View‑Pages unverändert.
Destructive actions: tenant_membership.remove und role‑demote sind destruktive — implemented via Action::make(...)->action(...)->requiresConfirmation() + policy checks.
Asset strategy: keine globalen Assets; on‑demand/load as before. Deployment: filament:assets nicht erforderlich für diese PR.
Testing plan: Livewire/Filament Komponenten + actions abgedeckt — RelationManager CRUD, Last‑Owner‑Guard, BreakGlassRecovery, CapabilityResolver/Registry, Tenant switcher + deny‑as‑not‑found route tests.
Offene/optionale Punkte

T005/T028/T029 (tenant_role_mappings migration + UI + Tests) sind optional und noch nicht umgesetzt.
Checklist (aus tasks.md)

 T001–T003 Discovery
 T004, T006–T007 Migrations (T005 optional)
 T008–T013 Models/Capabilities/Gates
 T014–T016 Tenant isolation & route enforcement
 T017–T021 Membership UI + bootstrap flows
 T022–T023 Audit logging + tests
 T024–T027 Break‑glass flows & tests
 T005, T028, T029 Optional mappings
 T030–T031 Formatting + focused tests
Migration / Test commands to run locally

vendor/bin/sail up -d
vendor/bin/sail artisan migrate
vendor/bin/sail artisan tinker (falls manuell Benutzer/Flags setzen)
vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure
Wenn du einen PR‑Titel und Labels willst, schlage ich vor:

Title: feat(062): Tenant RBAC v1 — memberships, capability resolver, break‑glass recovery
Labels: feature, tests, migration

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #74
2026-01-25 15:27:39 +00:00
a0ed9e24c5 feat: unify provider connection actions and notifications (#73)
## Summary
- introduce the Provider Connection Filament resource (list/create/edit) with DB-only controls, grouped action dropdowns, and badge-driven status/health rendering
- wire up the provider foundation stack (migrations, models, policies, providers, operations, badges, and audits) plus the required spec docs/checklists
- standardize Inventory Sync notifications so the job no longer writes its own DB rows; terminal notifications now flow exclusively through OperationRunCompleted while the start surface still shows the queued toast

## Testing
- ./vendor/bin/sail php ./vendor/bin/pint --dirty
- ./vendor/bin/sail artisan test tests/Unit/Badges/ProviderConnectionBadgesTest.php
- ./vendor/bin/sail artisan test tests/Feature/ProviderConnections tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
- ./vendor/bin/sail artisan test tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/Inventory/InventorySyncStartSurfaceTest.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #73
2026-01-25 01:01:37 +00:00
1bc6600fcc feat: tag badge catalog (060) (#72)
Summary:

completes Feature 060 by adding the suite-wide TagBadge catalog (spec/domain/renderer) plus migration notes/tests/docs/specs/plan/checklist.
standardizes all inert “tag-like” badges (policy type/category/platform, tenant environment, backup schedule frequency, etc.) to use the new catalog so only neutral colors are emitted.
fixes remaining Feature 059 regressions (inventory run/restore badges, Inventory Coverage tables, Boolean-enabled streak) and adds the BooleanEnabled badge mappings/guards/tests plus new QA tasks/checklist.
Testing:

BooleanEnabledBadgesTest.php
PolicyGeneralViewTest.php
PolicySettingsStandardViewTest.php
SettingsCatalogPolicyNormalizedDisplayTest.php
PolicyViewSettingsCatalogReadableTest.php (partial/visual checks skipped)
TagBadgeCatalogTest.php
TagBadgePaletteInvariantTest.php
NoForbiddenTagBadgeColorsTest.php
NoAdHocStatusBadgesTest.php
Manual QA per quickstart.md confirmed.
Next steps:

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #72
2026-01-23 23:05:55 +00:00
0b6600b926 059-unified-badges (#71)
## Summary
- centralize all status-like badge semantics via `BadgeCatalog`/`BadgeRenderer` and new per-domain mappings plus coverage for every affected entity
- replace ad-hoc badge colors in Filament tables/views with the shared catalog and add a guard test that blocks new inline semantics
- stabilize restore views by avoiding `@php(...)` shorthand so Blade compiles cleanly, and document BADGE-001 in the constitution/templates

## Testing
- `vendor/bin/sail php vendor/bin/pint --dirty`
- `vendor/bin/sail artisan test tests/Unit/Badges tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php`
- `vendor/bin/sail artisan test tests/Feature/RestoreRunWizardMetadataTest.php tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php`

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #71
2026-01-22 23:44:51 +00:00
e1ed7ae232 058-tenant-ui-polish (#70)
Kurzbeschreibung

Filament-native UI-Polish für das Tenant-Dashboard und zugehörige Inventory/Operations-Ansichten; entfernt alte custom Blade‑Panel-Wrapper (die die dicken Rahmen erzeugten) und ersetzt sie durch Filament‑Widgets (StatsOverview / TableWidget). Keine DB-Migrationen.
Änderungen (Kurz)

Dashboard: KPI‑Kacheln als StatsOverviewWidget (4 Tiles).
Needs‑Attention: sinnvolle Leerstaat‑UI (3 Health‑Checks + Links) und begrenzte, badge‑gestützte Issue‑Liste.
Recent Drift Findings & Recent Operations: Filament TableWidget (10 Zeilen), badge‑Spalten für Severity/Status/Outcome, kurze copyable IDs, freundliche Subject‑Labels statt roher UUIDs.
Entfernen der alten Blade-Wrapper, die ring- / shadow Klassen erzeugten.
Tests aktualisiert/ergänzt, um Tenant‑Scope und DB‑only Garantien zu prüfen.
Kleinigkeiten / UI‑Polish in Inventory/Operations-Listen und Panel‑Provider.
Wichtige Dateien (Auswahl)

DashboardKpis.php
NeedsAttention.php
RecentDriftFindings.php
RecentOperations.php
needs-attention.blade.php
Tests: TenantDashboardTenantScopeTest.php, inventory/operations test updates
Testing / Verifikation

Lokale Tests (empfohlen, vor Merge ausführen):
Formatter:
Filament assets (falls panel assets geändert wurden):
Review‑Hinweise (Was prüfen)

UI: Dashboard sieht visuell wie Filament‑Demo‑Widgets aus (keine dicken ring- Rahmen mehr).
Tables: Primary text zeigt freundliche Labels, nicht UUIDs; IDs sind copyable und kurz dargestellt.
Needs‑Attention: Leerstaat zeigt die 3 Health‑Checks + korrekte Links; bei Issues sind Badges und Farben korrekt.
Tenant‑Scope: Keine Daten von anderen Tenants leakieren (prüfe die aktualisierten TenantScope‑Tests).
Polling: Widgets poll nur wenn nötig (z.B. aktive Runs existieren).
Keine externen HTTP‑Calls oder ungeprüfte Jobs während Dashboard‑Rendering.
Deployment / Migrations

Keine Datenbankmigrationen.
Empfohlen: nach Merge ./vendor/bin/sail artisan filament:assets in Deployment‑Pipeline prüfen, falls neue panel assets registriert wurden.
Zusammenfassung für den Reviewer

Zweck: Entfernen der alten, handgebauten Panel‑Wrappers und Vereinheitlichung der Dashboard‑UX mit Filament‑nativen Komponenten; kleinere UI‑Polish in Inventory/Operations.
Tests: Unit/Feature tests für Tenant‑Scope und DB‑only Verhalten wurden aktualisiert; bitte laufen lassen.
Merge: Branch 058-tenant-ui-polish → dev (protected) via Pull Request in Gitea.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #70
2026-01-22 00:17:23 +00:00
ec9f28ccbd spec(057): refine Filament v5 upgrade spec (#69)
Aligns Feature 057 spec wording and requirements checklist for the Filament v5 upgrade.

No runtime/code changes.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #69
2026-01-21 14:12:26 +00:00
abda751296 feat(058): tenant dashboard + active-runs gating (#68)
Adds a tenant-scoped dashboard page (KPIs, Needs Attention, Recent Drift Findings, Recent Operations) with polling only while active runs exist.

Guardrails: DB-only render (no outbound HTTP) + tenant isolation.

Tests: ActiveRunsTest, TenantDashboardDbOnlyTest, TenantDashboardTenantScopeTest.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #68
2026-01-21 14:00:42 +00:00
5745461654 Merge pull request 'spec: refine 057 + extend 058' (#67) from 058-tenant-ui-polish into dev 2026-01-21 11:29:41 +00:00
Ahmed Darrazi
c41f264231 merge: agent session work 2026-01-21 12:29:16 +01:00
Ahmed Darrazi
6a2fe91547 Merge remote-tracking branch 'origin/dev' into 058-tenant-ui-polish-session-1768993496
# Conflicts:
#	specs/057-filament-v5-upgrade/checklists/requirements.md
#	specs/057-filament-v5-upgrade/spec.md
2026-01-21 12:29:09 +01:00
Ahmed Darrazi
8b17bbe9be spec: refine 057 + extend 058 2026-01-21 08:12:46 +01:00
Ahmed Darrazi
283daeab33 spec: tenant ui polish v1 2026-01-20 23:44:00 +01:00
Ahmed Darrazi
9870f5d102 chore: install Laravel Boost + MCP config 2026-01-20 23:36:08 +01:00
971105daa9 057-filament-v5-upgrade (#66)
Summary: Upgrade Filament to v5 (Livewire v4), replace Filament v4-only plugins, add first-party JSON renderer, and harden Monitoring/Ops UX guardrails.
What I changed:
Composer: upgraded filament/filament → v5, removed pepperfm/filament-json and lara-zeus/torch-filament, added torchlight/engine.
Views: replaced JSON viewer with json-viewer.blade.php and updated snapshot display.
Tests: added DB-only + tenant-isolation guard tests under Monitoring and OpsUx, plus Filament smoke tests.
Specs: added/updated specs/057-filament-v5-upgrade/* (spec, tasks, plan, quickstart, research).
Formatting: ran Pint; ran full test suite (641 passed, 5 skipped).
Validation:
Ran ./vendor/bin/sail artisan test (full suite) — all tests passed.
Ran ./vendor/bin/sail pint --dirty — formatting applied.
Ran npm run build locally (Vite) — assets generated.
Notes / Rollback:
Rollback: revert composer.json/composer.lock and build assets; documented in quickstart.md.
One pending app migration was noted during validation; ensure migrations are applied in staging before deploy.
Reviewers: @frontend, @backend (adjust as needed)
Spec links:
spec.md
tasks.md
quickstart.md

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #66
2026-01-20 21:19:27 +00:00
Ahmed Darrazi
9a8b283b1d merge: agent session work 2026-01-20 22:16:57 +01:00
Ahmed Darrazi
aa8d132f3c feat(057): Filament v5 - notifications shim, restore vendor notifications asset, polling fixes, tests 2026-01-20 21:22:32 +01:00
Ahmed Darrazi
a6ab36aca4 merge: origin/dev into 057-filament-v5-upgrade 2026-01-20 19:20:53 +01:00
Ahmed Darrazi
e442a865d6 merge: agent session work 2026-01-20 19:18:06 +01:00
Ahmed Darrazi
eac19118a2 feat: upgrade Filament to v5 2026-01-20 19:17:54 +01:00
a97beefda3 056-remove-legacy-bulkops (#65)
Kurzbeschreibung

Versteckt die Rerun-Row-Action für archivierte (soft-deleted) RestoreRuns und verhindert damit fehlerhafte Neu-Starts aus dem Archiv; ergänzt einen Regressionstest.
Änderungen

Code: RestoreRunResource.php — Sichtbarkeit der rerun-Action geprüft auf ! $record->trashed() und defensive Abbruchprüfung im Action-Handler.
Tests: RestoreRunRerunTest.php — neuer Test rerun action is hidden for archived restore runs.
Warum

Archivierte RestoreRuns durften nicht neu gestartet werden; UI zeigte trotzdem die Option. Das führte zu verwirrendem Verhalten und möglichen Fehlern beim Enqueueing.
Verifikation / QA

Unit/Feature:
./vendor/bin/sail artisan test tests/Feature/RestoreRunRerunTest.php
Stil/format:
./vendor/bin/pint --dirty
Manuell (UI):
Als Tenant-Admin Filament → Restore Runs öffnen.
Filter Archived aktivieren (oder Trashed filter auswählen).
Sicherstellen, dass für archivierte Einträge die Rerun-Action nicht sichtbar ist.
Auf einem aktiven (nicht-archivierten) Run prüfen, dass Rerun sichtbar bleibt und wie erwartet eine neue RestoreRun erzeugt.
Wichtige Hinweise

Kein DB-Migration required.
Diese PR enthält nur den UI-/Filament-Fix; die zuvor gemachten operative Fixes für Queue/adapter-Reconciliation bleiben ebenfalls auf dem Branch (z. B. frühere commits während der Debugging-Session).
T055 (Schema squash) wurde bewusst zurückgestellt und ist nicht Teil dieses PRs.
Merge-Checklist

 Tests lokal laufen (RestoreRunRerunTest grünt)
 Pint läuft ohne ungepatchte Fehler
 Branch gepusht: 056-remove-legacy-bulkops (PR-URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...056-remove-legacy-bulkops)

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #65
2026-01-19 23:27:52 +00:00
Ahmed Darrazi
ec99c6519c fix: green test suite after dev merge 2026-01-20 00:26:24 +01:00
Ahmed Darrazi
1ed3b953da merge: dev into 056-remove-legacy-bulkops 2026-01-19 22:28:36 +01:00
Ahmed Darrazi
6737ba7d85 fix(filament): hide restore rerun when archived 2026-01-19 22:11:45 +01:00
Ahmed Darrazi
b807a7bb96 fix(queue): handle legacy bulk restore job payloads 2026-01-19 21:21:02 +01:00
Ahmed Darrazi
cb3da561ef fix(ops-ux): treat restore previews as completed 2026-01-19 19:43:11 +01:00
Ahmed Darrazi
c352bc9a17 merge: agent session work 2026-01-19 19:13:41 +01:00
Ahmed Darrazi
7b96ef8dd8 feat(ops-ux): harden run failures + retry policy 2026-01-19 19:01:36 +01:00
Ahmed Darrazi
5118497da9 wip: feature 056 progress 2026-01-19 18:50:11 +01:00
Ahmed Darrazi
bcdeeb5525 spec: refine 056 docs 2026-01-18 18:29:06 +01:00
bd6df1f343 055-ops-ux-rollout (#64)
Kurzbeschreibung

Implementiert Feature 055 — Ops‑UX Constitution Rollout v1.3.0.
Behebt: globales BulkOperationProgress-Widget benötigt keinen manuellen Refresh mehr; ETA/Elapsed aktualisieren korrekt; Widget verschwindet automatisch.
Verbesserungen: zuverlässiges polling (Alpine factory + Livewire fallback), sofortiger Enqueue‑Signal-Dispatch, Failure‑Message‑Sanitization, neue Guard‑ und Regressionstests, Specs/Tasks aktualisiert.
Was geändert wurde (Auszug)

InventoryLanding.php
bulk-operation-progress.blade.php
OperationUxPresenter.php
SyncRestoreRunToOperationRun.php
PolicyResource.php
PolicyVersionResource.php
RestoreRunResource.php
tests/Feature/OpsUx/* (PollerRegistration, TerminalNotificationFailureMessageTest, CanonicalViewRunLinksTest, OperationCatalogCoverageTest, UnknownOperationTypeLabelTest)
InventorySyncButtonTest.php
tasks.md
Tests

Neue Tests hinzugefügt; php artisan test --group=ops-ux lokal grün (alle relevanten Tests laufen).
How to verify manually

Auf Branch wechseln: 055-ops-ux-rollout
In Filament: Inventory → Sync (oder relevante Bulk‑Aktion) auslösen.
Beobachten: Progress‑Widget erscheint sofort, ETA/Elapsed aktualisiert, Widget verschwindet nach Fertigstellung ohne Browser‑Refresh.
Optional: ./vendor/bin/sail exec app php artisan test --filter=OpsUx oder php artisan test --group=ops-ux
Besonderheiten / Hinweise

Einzelne, synchrone Policy‑Actions (ignore/restore/PolicyVersion single archive/restore/forceDelete) sind absichtlich inline und erzeugen kein OperationRun. Bulk‑Aktionen und restore.execute werden als Runs modelliert. Wenn gewünscht, kann ich die inline‑Actions auf OperationRunService umstellen, damit sie in Monitoring → Operations sichtbar werden.
Remote: Branch ist bereits gepusht (origin/055-ops-ux-rollout). PR kann in Gitea erstellt werden.
Links

Specs & tasks: tasks.md
Monitoring page: Operations.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #64
2026-01-18 14:50:15 +00:00
Ahmed Darrazi
af17875b9d merge: dev conflict resolution 2026-01-18 15:47:13 +01:00
Ahmed Darrazi
45f40d0a08 merge: origin/dev into 055-ops-ux-rollout 2026-01-18 15:46:49 +01:00
Ahmed Darrazi
37873829fd test(ops-ux): add US4 guardrails 2026-01-18 14:47:51 +01:00
Ahmed Darrazi
b62d7c2ca5 merge: ops-ux session work 2026-01-18 14:44:24 +01:00
Ahmed Darrazi
8b9ab52138 feat(ops-ux): constitution rollout v1.3.0 2026-01-18 14:44:16 +01:00
3030dd9af2 054-unify-runs-suitewide (#63)
Summary

Kurz: Implementiert Feature 054 — canonical OperationRun-flow, Monitoring UI, dispatch-safety, notifications, dedupe, plus small UX safety clarifications (RBAC group search delegated; Restore group mapping DB-only).
What Changed

Core service: OperationRun lifecycle, dedupe and dispatch helpers — OperationRunService.php.
Model + migration: OperationRun model and migration — OperationRun.php, 2026_01_16_180642_create_operation_runs_table.php.
Notifications: queued + terminal DB notifications (initiator-only) — OperationRunQueued.php, OperationRunCompleted.php.
Monitoring UI: Filament list/detail + Livewire pieces (DB-only render) — OperationRunResource.php and related pages/views.
Start surfaces / Jobs: instrumented start surfaces, job middleware, and job updates to use canonical runs — multiple app/Jobs/* and app/Filament/* updates (see tests for full coverage).
RBAC + Restore UX clarifications: RBAC group search is delegated-Graph-based and disabled without delegated token; Restore group mapping remains DB-only (directory cache) and helper text always visible — TenantResource.php, RestoreRunResource.php.
Specs / Constitution: updated spec & quickstart and added one-line constitution guideline about Graph usage:
spec.md
quickstart.md
constitution.md
Tests & Verification

Unit / Feature tests added/updated for run lifecycle, notifications, idempotency, and UI guards: see tests/Feature/* (notably OperationRunServiceTest, MonitoringOperationsTest, OperationRunNotificationTest, and various Filament feature tests).
Full test run locally: ./vendor/bin/sail artisan test → 587 passed, 5 skipped.
Migrations

Adds create_operation_runs_table migration; run php artisan migrate in staging after review.
Notes / Rationale

Monitoring pages are explicitly DB-only at render time (no Graph calls). Start surfaces enqueue work only and return a “View run” link.
Delegated Graph access is used only for explicit user actions (RBAC group search); restore mapping intentionally uses cached DB data only to avoid render-time Graph calls.
Dispatch wrapper marks runs failed immediately if background dispatch throws synchronously to avoid misleading “queued” states.
Upgrade / Deploy Considerations

Run migrations: ./vendor/bin/sail artisan migrate.
Background workers should be running to process queued jobs (recommended to monitor queue health during rollout).
No secret or token persistence changes.
PR checklist

 Tests updated/added for changed behavior
 Specs updated: 054-unify-runs-suitewide docs + quickstart
 Constitution note added (.specify)
 Pint formatting applied

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #63
2026-01-17 22:25:00 +00:00
Ahmed Darrazi
39287b250b merge: agent session work 2026-01-17 23:19:15 +01:00
Ahmed Darrazi
94f8719e09 merge: agent nested session work 2026-01-17 23:18:57 +01:00
Ahmed Darrazi
9f980ce80e feat(054): finalize docs — RBAC delegated group search + Restore DB-only mapping; constitution note 2026-01-17 23:14:20 +01:00
Ahmed Darrazi
48b558db93 docs: unified operations runs specs and plan (054) 2026-01-16 19:06:30 +01:00
30ad57baab feat/053-unify-runs-monitoring (#60)
Summary

This PR introduces Unified Operations Runs + Monitoring Hub (053).

Goal: Standardize how long-running operations are tracked and monitored using the existing tenant-scoped run record (BulkOperationRun) as the canonical “operation run”, and surface it in a single Monitoring → Operations hub (view-only, tenant-scoped, role-aware).

Phase 1 adoption scope (per spec):
	•	Drift generation (drift.generate)
	•	Backup Set “Add Policies” (backup_set.add_policies)

Note: This PR does not convert every run type yet (e.g. GroupSyncRuns / InventorySyncRuns remain separate for now). This is intentionally incremental.

⸻

What changed

Monitoring / Operations hub
	•	Moved/organized run monitoring under Monitoring → Operations
	•	Added:
	•	status buckets (queued / running / succeeded / partially succeeded / failed)
	•	filters (run type, status bucket, time range)
	•	run detail “Related” links (e.g. Drift findings, Backup Set context)
	•	All hub pages are DB-only and view-only (no rerun/cancel/delete actions)

Canonical run semantics
	•	Added canonical helpers on BulkOperationRun:
	•	runType() (resource.action)
	•	statusBucket() derived from status + counts (testable semantics)

Drift integration (Phase 1)
	•	Drift generation start behavior now:
	•	creates/reuses a BulkOperationRun with drift context payload (scope_key + baseline/current run ids)
	•	dispatches generation job
	•	emits DB notifications including “View run” link
	•	On generation failure: stores sanitized failure entries + sends failure notification

Permissions / tenant isolation
	•	Monitoring run list/view is tenant-scoped and returns 403 for cross-tenant access
	•	Readonly can view runs but cannot start drift generation

⸻

Tests

Added/updated Pest coverage:
	•	BulkOperationRunStatusBucketTest.php
	•	DriftGenerationDispatchTest.php
	•	GenerateDriftFindingsJobNotificationTest.php
	•	RunAuthorizationTenantIsolationTest.php

Validation run locally:
	•	./vendor/bin/pint --dirty
	•	targeted tests from feature quickstart / drift monitoring tests

⸻

Manual QA
	1.	Go to Monitoring → Operations
	•	verify filters (run type / status / time range)
	•	verify run detail shows counts + sanitized failures + “Related” links
	2.	Open Drift Landing
	•	with >=2 successful inventory runs for scope: should queue drift generation + show notification with “View run”
	•	as readonly: should not start generation
	3.	Run detail
	•	drift.generate runs show “Drift findings” related link
	•	failure entries are sanitized (no secrets/tokens/raw payload dumps)

⸻

Notes / Ops
	•	Queue workers must be restarted after deploy so they load the new code:
	•	php artisan queue:restart (or Sail equivalent)
	•	This PR standardizes monitoring for Phase 1 producers only; follow-ups will migrate additional run types into the unified pattern.

⸻

Spec / Docs
	•	SpecKit artifacts added under specs/053-unify-runs-monitoring/
	•	Checklists are complete:
	•	requirements checklist PASS
	•	writing checklist PASS

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #60
2026-01-16 15:10:31 +00:00
c60d16ffba feat/052-async-add-policies (#59)
Status Update

Committed the async “Add selected” flow: job-only handler, deterministic run reuse, sanitized failure tracking, observation updates, and the new BulkOperationService/Progress test coverage.
All relevant tasks in tasks.md are marked done, and the checklist under requirements.md is fully satisfied (PASS).
Ran ./vendor/bin/pint --dirty plus BackupSetPolicyPickerTableTest.php—all green.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #59
2026-01-15 22:20:16 +00:00
a449ecec5b feat/044-drift-mvp (#58)
Beschreibung
Implementiert das Drift MVP Feature (Spec: 044-drift-mvp) mit Fokus auf automatische Drift-Erkennung zwischen Inventory Sync Runs und Bulk-Triage für Findings.

Was wurde implementiert?
Drift-Erkennung: Vergleicht Policy-Snapshots, Assignments und Scope Tags zwischen Baseline- und Current-Runs. Deterministische Fingerprints verhindern Duplikate.
Findings UI: Neue Filament Resource für Findings mit Listen- und Detail-Ansicht. DB-only Diffs (keine Graph-Calls zur Laufzeit).
Bulk Acknowledge:
"Acknowledge selected" (Bulk-Action auf der Liste)
"Acknowledge all matching" (Header-Action, respektiert aktuelle Filter; Type-to-Confirm bei >100 Findings)
Scope Tag Fix: Behebt False Positives bei Legacy-Daten ohne scope_tags.ids (inferiert Default-Werte).
Authorization: Tenant-isoliert, Rollen-basiert (Owner/Manager/Operator können acknowledge).
Tests: Vollständige Pest-Coverage (28 Tests, 347 Assertions) für Drift-Logik, UI und Bulk-Actions.
Warum diese Änderungen?
Problem: Keine automatisierte Drift-Erkennung; manuelle Triage bei vielen Findings ist mühsam.
Lösung: Async Drift-Generierung mit persistenter Findings-Tabelle. Safe Bulk-Tools für Massen-Triage ohne Deletes.
Konformität: Folgt AGENTS.md Workflow, Spec-Kit (Tasks + Checklists abgehakt), Laravel/Filament Best Practices.
Technische Details
Neue Dateien: ~40 (Models, Services, Tests, Views, Migrations)
Änderungen: Filament Resources, Jobs, Policies
DB: Neue findings Tabelle (JSONB für Evidence, Indexes für Performance)
Tests: ./vendor/bin/sail artisan test tests/Feature/Drift --parallel → 28 passed
Migration: ./vendor/bin/sail artisan migrate (neue Tabelle + Indexes)
Screenshots / Links
Spec: spec.md
Tasks: tasks.md (alle abgehakt)
UI: Findings-Liste mit Bulk-Actions; Detail-View mit Diffs
Checklist
 Tests passieren (parallel + serial)
 Code formatiert (./vendor/bin/pint --dirty)
 Migration reversibel
 Tenant-Isolation enforced
 No Graph-Calls in Views
 Authorization checks
 Spec + Tasks aligned
Deployment Notes
Neue Migration: create_findings_table
Neue Permissions: drift.view, drift.acknowledge
Queue-Job: GenerateDriftFindingsJob (async, deduped)
2026-01-14 23:16:10 +00:00
bc846d7c5c 051-entra-group-directory-cache (#57)
Summary

Adds a tenant-scoped Entra Groups “Directory Cache” to enable DB-only group name resolution across the app (no render-time Graph calls), plus sync runs + observability.

What’s included
	•	Entra Groups cache
	•	New entra_groups storage (tenant-scoped) for group metadata (no memberships).
	•	Retention semantics: groups become stale / retained per spec (no hard delete on first miss).
	•	Group Sync Runs
	•	New “Group Sync Runs” UI (list + detail) with tenant isolation (403 on cross-tenant access).
	•	Manual “Sync Groups” action: creates/reuses a run, dispatches job, DB notification with “View run” link.
	•	Scheduled dispatcher command wired in console.php.
	•	DB-only label resolution (US3)
	•	Shared EntraGroupLabelResolver with safe fallback Unresolved (…last8) and UUID guarding.
	•	Refactors to prefer cached names (no typeahead / no live Graph) in:
	•	Tenant RBAC group selects
	•	Policy version assignments widget
	•	Restore results + restore wizard group mapping labels

Safety / Guardrails
	•	No render-time Graph calls: fail-hard guard test verifies UI paths don’t call GraphClientInterface during page render.
	•	Tenant isolation & authorization: policies + scoped queries enforced (cross-tenant access returns 403, not 404).
	•	Data minimization: only group metadata is cached (no membership/owners).

Tests / Verification
	•	Added/updated tests under tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups:
	•	Start sync → run record + job dispatch + upserts
	•	Retention purge semantics
	•	Scheduled dispatch wiring
	•	Render-time Graph guard
	•	UI/resource access isolation
	•	Ran:
	•	./vendor/bin/pint --dirty
	•	./vendor/bin/sail artisan test tests/Feature/DirectoryGroups
	•	./vendor/bin/sail artisan test tests/Unit/DirectoryGroups

Notes / Follow-ups
	•	UI polish remains (picker/lookup UX, consistent progress widget/toasts across modules, navigation grouping).
	•	pr-gate checklist still has non-blocking open items (mostly UX/ops polish); requirements gate is green.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #57
2026-01-11 23:24:12 +00:00
bcf4996a1e feat/049-backup-restore-job-orchestration (#56)
Summary

This PR implements Spec 049 – Backup/Restore Job Orchestration: all critical Backup/Restore execution paths are job-only, idempotent, tenant-scoped, and observable via run records + DB notifications (Phase 1). The UI no longer performs heavy Graph work inside request/Filament actions for these flows.

Why

We want predictable UX and operations at MSP scale:
	•	no timeouts / long-running requests
	•	reproducible run state + per-item results
	•	safe error persistence (no secrets / no token leakage)
	•	strict tenant isolation + auditability for write paths

What changed

Foundational (Runs + Idempotency + Observability)
	•	Added a shared RunIdempotency helper (dedupe while queued/running).
	•	Added a read-only BulkOperationRuns surface (list + view) for status/progress.
	•	Added DB notifications for run status changes (with “View run” link).

US1 – Policy “Capture snapshot” is job-only
	•	Policy detail “Capture snapshot” now:
	•	creates/reuses a run (dedupe key: tenant + policy.capture_snapshot + policy DB id)
	•	dispatches a queued job
	•	returns immediately with notification + link to run detail
	•	Graph capture work moved fully into the job; request path stays Graph-free.

US3 – Restore runs orchestration is job-only + safe
	•	Live restore execution is queued and updates RestoreRun status/progress.
	•	Per-item outcomes are persisted deterministically (per internal DB record).
	•	Audit logging is written for live restore.
	•	Preview/dry-run is enforced as read-only (no writes).

Tenant isolation / authorization (non-negotiable)
	•	Run list/view/start are tenant-scoped and policy-guarded (cross-tenant access => 403, not 404).
	•	Explicit Pest tests cover cross-tenant denial and start authorization.

Tests / Verification
	•	./vendor/bin/pint --dirty
	•	Targeted suite (examples):
	•	policy capture snapshot queued + idempotency tests
	•	restore orchestration + audit logging + preview read-only tests
	•	run authorization / tenant isolation tests

Notes / Scope boundaries
	•	Phase 1 UX = DB notifications + run detail page. A global “progress widget” is tracked as Phase 2 and not required for merge.
	•	Resilience/backoff is tracked in tasks but can be iterated further after merge.

Review focus
	•	Dedupe behavior for queued/running runs (reuse vs create-new)
	•	Tenant scoping & policy gates for all run surfaces
	•	Restore safety: audit event + preview no-writes

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #56
2026-01-11 15:59:06 +00:00
060a82a1ed feat/048-backup-restore-ui-graph-safety (#55)
Feature 048: Backup/Restore UI Graph-Safety (Phase 1)

Dieses PR entfernt Microsoft Graph Calls aus UI-Renderpfaden (Filament/Livewire mount/render/options/typeahead/labels) in den kritischen Backup/Restore Screens und fügt Fail-Hard Guard Tests hinzu, die regressionssicher verhindern, dass UI-Rendering wieder Graph aufruft.

⸻

Motivation

Backup/Restore UI wurde teilweise “fragil”, weil UI-Komponenten (z.B. Group Typeahead/Option Labels) Graph/Entra direkt beim Rendern triggern konnten. Das führt zu:
	•	langsamen/unstabilen Seiten (429/Timeout/Permissions)
	•	schwer reproduzierbaren UI-Fehlern im MSP-Scale
	•	unnötiger Kopplung von “Page render” an Graph-Verfügbarkeit

Ziel: UI muss DB-only rendern; Graph darf nur in Jobs/Run-Execution stattfinden.

⸻

Scope / Changes

1) Restore Wizard: Entfernt Graph-Typeahead & Label-Resolution
	•	Group Mapping ist jetzt DB-only:
	•	manuelle GUID Eingabe / Skip
	•	GUID Validation
	•	Helper Text, wo die Object ID zu finden ist
	•	Keine Graph calls mehr in options() / getOptionLabelUsing() / typeahead beim Rendern.

2) Fail-Hard Guard Tests (Graph-Safety)
	•	Neue Test-Infrastruktur: FailHardGraphClient (GraphClientInterface darf nicht aufgerufen werden)
	•	Guard Tests als Pest Feature Tests (HTTP GET):
	•	Backup Sets Index rendert mit fail-hard Graph client
	•	Restore Wizard Route rendert mit fail-hard Graph client
	•	Assertions:
	•	200 OK
	•	plus stable UI marker string
	•	Masking/Fallback Format ist deterministisch: Unresolved (…<last8>)

3) Spec/Plan/Tasks/Checklist
	•	Spec 048 aktualisiert, Tasks abgehakt
	•	requirements.md Checklist Gate: PASS

⸻

Out of Scope / Non-Goals
	•	Kein Umbau der “Execution”-Actions zu Jobs (Capture snapshot, Restore rerun, Dry-Run execution etc.) → eigener Folge-Spec (Phase 2).
	•	Keine Entra Group Name Resolution (separates Group-Inventory/Cache Feature).
	•	Keine neuen Tabellen/Migrations in Phase 1.

⸻

How to verify (manual)

Mit absichtlich kaputtem Tenant/Auth (Graph failt):
	1.	Öffne Backups & Restore → Backup Sets
 muss laden (UI render DB-only)
	2.	Öffne Restore Runs → Create Restore Run (Wizard)
 muss laden, kein Group-Typeahead mehr
	3.	Starte eine Restore Operation
 darf fehlschlagen (Graph kaputt) – wichtig ist: Render bleibt stabil, Run zeigt Fehler sauber pro Item.

⸻

Tests / Validation

Executed:
	•	./vendor/bin/pint --dirty 
	•	./vendor/bin/sail artisan test tests/Feature/Filament/BackupSetGraphSafetyTest.php tests/Feature/Filament/RestoreWizardGraphSafetyTest.php 
	•	(optional) Combined targeted suite 

⸻

Notes
	•	This PR intentionally focuses on UI Graph-Safety only.
	•	Any future reintroduction of Graph search/typeahead in UI must go through contracts first and be executed asynchronously, never in UI render paths.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #55
2026-01-11 00:14:35 +00:00
943 changed files with 73713 additions and 6948 deletions

View File

@ -0,0 +1,173 @@
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- 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.
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/render-hooks
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- 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`.
Sources:
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- 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.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
- Assets policy:
- Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`.
Sources:
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- 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.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
- User menu:
- Configure via `userMenuItems()` with Action objects.
- Never put destructive actions there without confirmation + authorization.
Sources:
- https://filamentphp.com/docs/5.x/navigation/overview
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
- Otherwise disable global search for that resource (dont “expect it to work”).
- If global search renders relationship-backed details: eager-load via global search query override.
- For very large datasets: consider disabling term splitting (only when needed).
Sources:
- https://filamentphp.com/docs/5.x/resources/overview
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- 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.
Sources:
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
Sources:
- https://filamentphp.com/docs/5.x/resources/managing-relationships
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- 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).
- Custom field views must obey state binding modifiers.
Sources:
- https://filamentphp.com/docs/5.x/forms/overview
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
- Destructive actions add `->requiresConfirmation()`.
- Navigation-only actions should use `->url(...)`.
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
Sources:
- https://filamentphp.com/docs/5.x/tables/empty-state
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- 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.
Sources:
- https://filamentphp.com/docs/5.x/users/overview
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- 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.
Sources:
- https://filamentphp.com/docs/5.x/notifications/overview
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- 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.
Sources:
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/styling/css-hooks
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
Sources:
- https://filamentphp.com/docs/5.x/testing/overview
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
- Destructive actions without `->requiresConfirmation()`.
- Shipping heavy assets globally when on-demand loading fits.
- Publishing Filament internal views as a default customization technique.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
4) Which actions are destructive and how confirmation + authorization is handled.
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
6) Testing plan: which pages/widgets/relation managers/actions are covered.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions

View File

@ -0,0 +1,79 @@
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- 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).
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] 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”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
- [ ] Cluster directory structure is treated as recommended, not mandatory.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- 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.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] 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”
- [ ] 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”
## Forms
- [ ] 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”
- [ ] 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”
## Tables & Actions
- [ ] 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”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- 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.
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] 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)”
## Performance
- [ ] 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”
- [ ] 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”
## Testing
- [ ] 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?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `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”

View File

@ -1,14 +1,21 @@
node_modules/
vendor/
.git/
.DS_Store
Thumbs.db
.env
.env.*
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.tmp
*.swp
public/build/
public/hot/
public/storage/
storage/framework/
storage/logs/
storage/debugbar/
storage/*.key
/references/

View File

@ -63,3 +63,13 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Entra ID (OIDC) - Tenant Admin (/admin) sign-in
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
ENTRA_AUTHORITY_TENANT=organizations
# System panel break-glass (Platform Operators)
BREAK_GLASS_ENABLED=false
BREAK_GLASS_TTL_MINUTES=60

View File

@ -1,5 +1,14 @@
{
"general": {
"previewFeatures": false
}
"general": {
"previewFeatures": false
},
"mcpServers": {
"laravel-boost": {
"command": "vendor/bin/sail",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View File

@ -10,6 +10,12 @@ ## Active Technologies
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -29,9 +35,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
<!-- MANUAL ADDITIONS START -->

669
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,669 @@
<laravel-boost-guidelines>
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- 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.
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/render-hooks
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- 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`.
Sources:
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- 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.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
- Assets policy:
- Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`.
Sources:
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- 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.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
- User menu:
- Configure via `userMenuItems()` with Action objects.
- Never put destructive actions there without confirmation + authorization.
Sources:
- https://filamentphp.com/docs/5.x/navigation/overview
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
- Otherwise disable global search for that resource (dont “expect it to work”).
- If global search renders relationship-backed details: eager-load via global search query override.
- For very large datasets: consider disabling term splitting (only when needed).
Sources:
- https://filamentphp.com/docs/5.x/resources/overview
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- 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.
Sources:
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
Sources:
- https://filamentphp.com/docs/5.x/resources/managing-relationships
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- 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).
- Custom field views must obey state binding modifiers.
Sources:
- https://filamentphp.com/docs/5.x/forms/overview
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
- Destructive actions add `->requiresConfirmation()`.
- Navigation-only actions should use `->url(...)`.
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
Sources:
- https://filamentphp.com/docs/5.x/tables/empty-state
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- 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.
Sources:
- https://filamentphp.com/docs/5.x/users/overview
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- 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.
Sources:
- https://filamentphp.com/docs/5.x/notifications/overview
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- 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.
Sources:
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/styling/css-hooks
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
Sources:
- https://filamentphp.com/docs/5.x/testing/overview
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
- Destructive actions without `->requiresConfirmation()`.
- Shipping heavy assets globally when on-demand loading fits.
- Publishing Filament internal views as a default customization technique.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
4) Which actions are destructive and how confirmation + authorization is handled.
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
6) Testing plan: which pages/widgets/relation managers/actions are covered.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- 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).
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] 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”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
- [ ] Cluster directory structure is treated as recommended, not mandatory.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- 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.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] 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”
- [ ] 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”
## Forms
- [ ] 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”
- [ ] 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”
## Tables & Actions
- [ ] 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”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- 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.
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] 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)”
## Performance
- [ ] 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”
- [ ] 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”
## Testing
- [ ] 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?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `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”
=== foundation rules ===
# 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.
## 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.
- php - 8.4.15
- filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## 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.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## 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.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## 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.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## 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.
## Tinker / Debugging
- 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.
## Reading Browser Logs With the `browser-logs` Tool
- 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.
## 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.
- 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.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- 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
- 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'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
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
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules ===
## Laravel 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`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## 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.
- 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 ===
## 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.
- 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.
### Database
- 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.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### 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`.
### 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.
### Controllers & Validation
- 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.
### Queues
- 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.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### 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')`.
### 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.
- 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.
### 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`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- 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()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- 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.
### 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.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### 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 ===
## 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.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- 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.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- 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 ===
## Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- 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.
- 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>

4
.gitignore vendored
View File

@ -1,6 +1,7 @@
*.log
.DS_Store
.env
.env.*
.env.backup
.env.production
.phpactor.json
@ -21,7 +22,10 @@ coverage/
/public/storage
/storage/*.key
/storage/pail
/storage/framework
/storage/logs
/vendor
/bootstrap/cache
Homestead.json
Homestead.yaml
Thumbs.db

View File

@ -2,6 +2,7 @@ dist/
build/
public/build/
node_modules/
vendor/
*.log
.env
.env.*

View File

@ -3,7 +3,11 @@ dist/
build/
public/build/
public/hot/
public/storage/
coverage/
vendor/
storage/
bootstrap/cache/
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@ -1,22 +1,19 @@
<!--
Sync Impact Report
- Version change: 1.0.0 → 1.1.0
- Version change: 1.5.0 → 1.6.0
- Modified principles:
- Safety-First Restore → Read/Write Separation by Default
- Auditability & Tenant Isolation → Tenant Isolation is Non-negotiable (+ Least Privilege)
- Graph Abstraction & Contracts → Single Contract Path to Graph
- Added principles:
- Inventory-first, Snapshots-second
- Deterministic Capabilities
- Automation must be Idempotent & Observable
- Data Minimization & Safe Logging
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
- Added sections:
- RBAC & UI Enforcement Standards (RBAC-UX)
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- ✅ .specify/templates/spec-template.md
- Follow-up TODOs:
- TODO(DELETED_STATUS): Keep “deleted” reserved for Feature 900 / Policy Lifecycle.
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None
-->
# TenantPilot Constitution
@ -47,25 +44,119 @@ ### Tenant Isolation is Non-negotiable
- Every read/write MUST be tenant-scoped.
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
- 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
deny-as-not-found (404).
### Automation must be Idempotent & Observable
### RBAC & UI Enforcement Standards (RBAC-UX)
RBAC Context — Planes, Roles, and Auditability
- The platform MUST maintain two strictly separated authorization planes:
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-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.
- Tenant role semantics MUST remain least-privilege:
- Readonly: view-only; MUST NOT start operations and MUST NOT mutate data.
- Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions.
- Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only).
- Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only.
- The system MUST prevent removing or demoting the last remaining Owner of a tenant.
- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, and MUST be redacted (no secrets).
RBAC-UX-001 — Server-side is the source of truth
- UI visibility / disabled state is never a security boundary.
- Every mutating action (create/update/delete/restore/archive/force-delete), every operation start, and every credential/
config change MUST enforce authorization server-side via `Gate::authorize(...)` or a Policy method.
- Any missing server-side authorization is a P0 security bug.
RBAC-UX-002 — Deny-as-not-found for non-members
- Tenant membership (and plane membership) is an isolation boundary.
- If the current actor is not a member of the current tenant (or otherwise not entitled to the 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
action endpoints (Livewire calls included).
RBAC-UX-003 — Capability denial is 403 (after membership is established)
- Within an established 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.
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
RBAC-UX-004 — Visible vs disabled UX rule
- For tenant members: actions SHOULD be visible but disabled when capability is missing.
- Disabled actions MUST provide helper text explaining the missing permission.
- For non-members: actions MUST behave as not found (404) and SHOULD NOT leak resource existence.
- Exception: highly sensitive controls (e.g., credential rotation) MAY be hidden even for members without permission.
RBAC-UX-005 — Destructive confirmation standard
- All destructive-like actions MUST require confirmation.
- Delete/force-delete/archive/restore/remove membership/role downgrade/credential rotation/break-glass enter/exit MUST use
`->requiresConfirmation()` and SHOULD include clear warning text.
- Confirmation is UX only; authorization still MUST be server-side.
RBAC-UX-006 — Capability registry is canonical
- Capabilities MUST be centrally defined in a single canonical registry (constants/enum).
- Feature code MUST reference capabilities only via the registry (no raw string literals).
- Role → capability mapping MUST reference only registry entries.
- CI MUST fail if unknown/unregistered capabilities are used.
RBAC-UX-007 — Global search must be tenant-safe
- Global search results MUST be scoped to the current tenant.
- 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).
RBAC-UX-008 — Regression guards are mandatory
- The repo MUST include RBAC regression tests asserting at least:
- Readonly cannot mutate or start operations.
- Operator can run allowed operations but cannot manage configuration.
- Manager/Owner behave according to the role matrix.
- The repo SHOULD include an automated “no ad-hoc authorization” guard that blocks new status/permission mappings sprinkled
across `app/Filament/**`, pushing patterns into central helpers.
### Operations / Run Observability Standard
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
- An action MUST create/reuse a canonical `OperationRun` and execute asynchronously when any of the following applies:
1. It can take > 2 seconds under normal conditions.
2. It performs remote/external calls (e.g., Microsoft Graph).
3. It is queued or scheduled.
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
- OPS-EX-AUTH-001 — Auth Handshake Exception:
- OIDC/SAML login handshakes MAY perform synchronous outbound HTTP (e.g., token exchange) without an `OperationRun`.
- Rationale: interactive, session-critical, and not a tenant-operational “background job”.
- Guardrail: outbound HTTP for auth handshakes is allowed only on `/auth/*` endpoints and MUST NOT occur on Monitoring/Operations pages.
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
including actor, tenant, action, target, before/after, and timestamp.
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- 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,
confirm + “View run”.
- 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
in failures or notifications.
- Graph calls are allowed only via explicit user interaction and only when delegated auth is present; never as a render side-effect (restore group mapping is intentionally DB-only).
- Monitoring → Operations is reserved for `OperationRun`-tracked operations.
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
- Long-running operations MUST create run records with status, counts, and stable error codes.
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
- Failures MUST be visible and actionable (no silent best-effort).
### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
- Logs MUST not contain secrets/tokens; monitoring MUST rely on run records + error codes (not log parsing).
### Badge Semantics Are Centralized (BADGE-001)
- Status-like badges (status/outcome/severity/risk/availability/boolean signals) MUST render via `BadgeCatalog` / `BadgeRenderer`.
- Filament resources/pages/widgets/views MUST NOT introduce ad-hoc status-like badge mappings (use a `BadgeDomain` instead).
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Spec-First Workflow
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
## Quality Gates
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
- Run `./vendor/bin/pint --dirty` before finalizing.
- Run `./vendor/bin/sail bin pint --dirty` before finalizing.
## Governance
@ -83,4 +174,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-07
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28

View File

@ -35,9 +35,14 @@ ## Constitution Check
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- 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: 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)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Automation: queued/scheduled ops are locked, idempotent, observable; handle 429/503 with backoff+jitter
- 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`
- 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
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
## Project Structure

View File

@ -77,8 +77,28 @@ ### Edge Cases
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls or any write/change behavior,
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(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.
**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`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- 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),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
<!--
ACTION REQUIRED: The content in this section represents placeholders.

View File

@ -9,6 +9,23 @@ # Tasks: [FEATURE NAME]
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
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
without an `OperationRun`.
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403,
- capability registry usage (no raw capability strings; no role-string checks in feature code),
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
- cross-plane deny-as-not-found (404) checks where applicable,
- at least one positive + one negative authorization test.
**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.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.

462
Agents.md
View File

@ -386,6 +386,264 @@ ## Reference Materials
===
<laravel-boost-guidelines>
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- 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.
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/render-hooks
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- 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`.
Sources:
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- 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.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
- Assets policy:
- Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`.
Sources:
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- 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.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
- User menu:
- Configure via `userMenuItems()` with Action objects.
- Never put destructive actions there without confirmation + authorization.
Sources:
- https://filamentphp.com/docs/5.x/navigation/overview
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
- Otherwise disable global search for that resource (dont “expect it to work”).
- If global search renders relationship-backed details: eager-load via global search query override.
- For very large datasets: consider disabling term splitting (only when needed).
Sources:
- https://filamentphp.com/docs/5.x/resources/overview
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- 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.
Sources:
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
Sources:
- https://filamentphp.com/docs/5.x/resources/managing-relationships
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- 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).
- Custom field views must obey state binding modifiers.
Sources:
- https://filamentphp.com/docs/5.x/forms/overview
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
- Destructive actions add `->requiresConfirmation()`.
- Navigation-only actions should use `->url(...)`.
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
Sources:
- https://filamentphp.com/docs/5.x/tables/empty-state
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- 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.
Sources:
- https://filamentphp.com/docs/5.x/users/overview
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- 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.
Sources:
- https://filamentphp.com/docs/5.x/notifications/overview
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- 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.
Sources:
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/styling/css-hooks
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
Sources:
- https://filamentphp.com/docs/5.x/testing/overview
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
- Destructive actions without `->requiresConfirmation()`.
- Shipping heavy assets globally when on-demand loading fits.
- Publishing Filament internal views as a default customization technique.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
4) Which actions are destructive and how confirmation + authorization is handled.
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
6) Testing plan: which pages/widgets/relation managers/actions are covered.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- 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).
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] 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”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
- [ ] Cluster directory structure is treated as recommended, not mandatory.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- 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.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] 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”
- [ ] 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”
## Forms
- [ ] 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”
- [ ] 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”
## Tables & Actions
- [ ] 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”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- 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.
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] 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)”
## Performance
- [ ] 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”
- [ ] 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”
## Testing
- [ ] 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?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `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”
=== foundation rules ===
# Laravel Boost Guidelines
@ -396,10 +654,11 @@ ## 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.
- php - 8.4.15
- filament/filament (FILAMENT) - v4
- filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- livewire/livewire (LIVEWIRE) - v3
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
@ -408,20 +667,19 @@ ## Foundational Context
- tailwindcss (TAILWINDCSS) - v4
## 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, 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()`.
- Check for existing components to reuse before writing a new one.
- UI consistency: Prefer Filament components (`<x-filament::section>`, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI.
## 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.
## 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.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `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.
@ -429,17 +687,16 @@ ## Replies
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## 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
- 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
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@ -450,22 +707,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific 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.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- 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.
- 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.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- 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
- 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'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
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".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
@ -476,7 +732,7 @@ ## PHP
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
@ -490,7 +746,7 @@ ### Type Declarations
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
- 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.
@ -498,32 +754,44 @@ ## PHPDoc Blocks
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules ===
## Laravel 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`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## 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.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` 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 ===
## Do Things the Laravel Way
- Use `php 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 `php artisan make:class`.
- 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`.
- 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
- 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.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### 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 `php 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
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
@ -547,41 +815,41 @@ ### Configuration
### 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.
- 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 `php 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
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `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 12
- Use the `search-docs` tool to get version specific documentation.
- Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- 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()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
- 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.
### 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.
- Laravel 11 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
- 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 Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
## 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.
- 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.
@ -598,15 +866,14 @@ ## Livewire Best Practices
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
<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">
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
@ -615,56 +882,17 @@ ## Testing Livewire
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
- You must run `vendor/bin/sail bin pint --dirty` 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.
=== pest/core rules ===
@ -673,7 +901,7 @@ ### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- 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.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
@ -686,9 +914,9 @@ ### Pest Tests
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- 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
@ -707,7 +935,7 @@ ### Mocking
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
- 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) {
@ -718,18 +946,17 @@ ### Datasets
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- 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 v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- 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).
@ -763,39 +990,37 @@ ### Example Tests
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- 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.
- 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>
- 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 4
## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- 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);
@ -811,9 +1036,8 @@ ## Tailwind 4
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
@ -830,3 +1054,11 @@ ### Replaced Utilities
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines>
## Active Technologies
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
- PostgreSQL (Sail)
- Tailwind CSS v4
## Recent Changes
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)

461
GEMINI.md
View File

@ -226,6 +226,264 @@ ## Reference Materials
===
<laravel-boost-guidelines>
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
# SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- 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.
- Destructive actions must execute via `Action::make(...)->action(...)` and include `->requiresConfirmation()` (no exceptions).
- Prefer render hooks + CSS hook classes over publishing Filament internal views.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/render-hooks
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- 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`.
Sources:
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- 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.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
- Assets policy:
- Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`.
Sources:
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- 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.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
- User menu:
- Configure via `userMenuItems()` with Action objects.
- Never put destructive actions there without confirmation + authorization.
Sources:
- https://filamentphp.com/docs/5.x/navigation/overview
- https://filamentphp.com/docs/5.x/navigation/clusters
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
- Otherwise disable global search for that resource (dont “expect it to work”).
- If global search renders relationship-backed details: eager-load via global search query override.
- For very large datasets: consider disabling term splitting (only when needed).
Sources:
- https://filamentphp.com/docs/5.x/resources/overview
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- 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.
Sources:
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
- Default performance stance: RelationManagers stay lazy-loaded unless explicit UX justification exists.
Sources:
- https://filamentphp.com/docs/5.x/resources/managing-relationships
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- 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).
- Custom field views must obey state binding modifiers.
Sources:
- https://filamentphp.com/docs/5.x/forms/overview
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
- Destructive actions add `->requiresConfirmation()`.
- Navigation-only actions should use `->url(...)`.
- UNVERIFIED: do not assert modal/confirmation behavior for URL-only actions unless verified.
Sources:
- https://filamentphp.com/docs/5.x/tables/empty-state
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- 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.
Sources:
- https://filamentphp.com/docs/5.x/users/overview
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- 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.
Sources:
- https://filamentphp.com/docs/5.x/notifications/overview
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- 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.
Sources:
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/styling/css-hooks
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
Sources:
- https://filamentphp.com/docs/5.x/testing/overview
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
- Destructive actions without `->requiresConfirmation()`.
- Shipping heavy assets globally when on-demand loading fits.
- Publishing Filament internal views as a default customization technique.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/actions/modals
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
3) For each globally searchable resource: whether it has Edit/View page (or global search is disabled).
4) Which actions are destructive and how confirmation + authorization is handled.
5) Asset strategy: global vs on-demand and where `filament:assets` runs in deploy.
6) Testing plan: which pages/widgets/relation managers/actions are covered.
Sources:
- https://filamentphp.com/docs/5.x/upgrade-guide
- https://filamentphp.com/docs/5.x/panel-configuration
- https://filamentphp.com/docs/5.x/resources/global-search
- https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- 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).
- [ ] Upgrade assumptions match the v5 upgrade guide requirements and steps.
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] 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”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Changing the path”
- [ ] Cluster usage is correctly configured (discovery + `$cluster` assignments).
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Creating a cluster”
- [ ] Cluster semantics (sub-navigation + grouped navigation behavior) are understood and verified against the clusters docs.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Introduction”
- [ ] Cluster directory structure is treated as recommended, not mandatory.
- Source: https://filamentphp.com/docs/5.x/navigation/clusters — “Code structure recommendations for panels using clusters”
- [ ] User menu items are registered via `userMenuItems()` and permission-gated where needed.
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- 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.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Setting global search result titles”
- [ ] Relationship-backed global search details are eager-loaded via the global search query override.
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] 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”
- [ ] 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”
## Forms
- [ ] 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”
- [ ] 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”
## Tables & Actions
- [ ] 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”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
- [ ] No checklist rule assumes confirmation/modals for `->url(...)` actions unless verified in docs (UNVERIFIED behavior must not be asserted as fact).
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- 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.
- [ ] Bulk operations intentionally choose between “Any” policy methods vs per-record authorization where required.
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] 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)”
## Performance
- [ ] 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”
- [ ] 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”
## Testing
- [ ] 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?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `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”
=== foundation rules ===
# Laravel Boost Guidelines
@ -236,10 +494,11 @@ ## 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.
- php - 8.4.15
- filament/filament (FILAMENT) - v4
- filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- livewire/livewire (LIVEWIRE) - v3
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
@ -248,7 +507,7 @@ ## Foundational Context
- tailwindcss (TAILWINDCSS) - v4
## 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, 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()`.
- Check for existing components to reuse before writing a new one.
@ -256,11 +515,11 @@ ## 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.
## 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.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `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.
@ -268,17 +527,16 @@ ## Replies
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## 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
- 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
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@ -289,22 +547,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific 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.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- 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.
- 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.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- 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
- 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'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
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".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
@ -315,7 +572,7 @@ ## PHP
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
@ -329,7 +586,7 @@ ### Type Declarations
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
- 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.
@ -337,32 +594,44 @@ ## PHPDoc Blocks
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules ===
## Laravel 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`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## 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.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` 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 ===
## Do Things the Laravel Way
- Use `php 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 `php artisan make:class`.
- 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`.
- 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
- 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.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### 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 `php 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
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
@ -386,41 +655,41 @@ ### Configuration
### 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.
- 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 `php 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
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `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 12
- Use the `search-docs` tool to get version specific documentation.
- Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- 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()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
- 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.
### 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.
- Laravel 11 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
- 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 Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
## 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.
- 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.
@ -437,15 +706,14 @@ ## Livewire Best Practices
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
<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">
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
@ -454,56 +722,17 @@ ## Testing Livewire
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
- You must run `vendor/bin/sail bin pint --dirty` 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.
=== pest/core rules ===
@ -512,7 +741,7 @@ ### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- 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.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
@ -525,9 +754,9 @@ ### Pest Tests
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- 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
@ -546,7 +775,7 @@ ### Mocking
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
- 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) {
@ -557,18 +786,17 @@ ### Datasets
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- 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 v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- 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).
@ -602,39 +830,37 @@ ### Example Tests
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- 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.
- 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>
- 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 4
## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- 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);
@ -650,9 +876,8 @@ ## Tailwind 4
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
@ -669,3 +894,11 @@ ### Replaced Utilities
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines>
## Recent Changes
- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
## Active Technologies
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)

View File

@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Services\AdapterRunReconciler;
use Illuminate\Console\Command;
use Throwable;
class OpsReconcileAdapterRuns extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ops:reconcile-adapter-runs
{--type= : Adapter run type (e.g. restore.execute)}
{--tenant= : Tenant ID}
{--older-than=60 : Only consider runs older than N minutes}
{--dry-run=true : Preview only (true/false)}
{--limit=50 : Max number of runs to inspect}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reconcile stale adapter-backed operation runs from DB-only source-of-truth records.';
/**
* Execute the console command.
*/
public function handle()
{
try {
/** @var AdapterRunReconciler $reconciler */
$reconciler = app(AdapterRunReconciler::class);
$type = $this->option('type');
$type = is_string($type) && trim($type) !== '' ? trim($type) : null;
$tenantId = $this->option('tenant');
$tenantId = is_numeric($tenantId) ? (int) $tenantId : null;
$olderThanMinutes = $this->option('older-than');
$olderThanMinutes = is_numeric($olderThanMinutes) ? (int) $olderThanMinutes : 60;
$olderThanMinutes = max(1, $olderThanMinutes);
$limit = $this->option('limit');
$limit = is_numeric($limit) ? (int) $limit : 50;
$limit = max(1, $limit);
$dryRun = $this->option('dry-run');
$dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
$dryRun = $dryRun ?? true;
$result = $reconciler->reconcile([
'type' => $type,
'tenant_id' => $tenantId,
'older_than_minutes' => $olderThanMinutes,
'limit' => $limit,
'dry_run' => $dryRun,
]);
$changes = $result['changes'] ?? [];
usort($changes, static fn (array $a, array $b): int => ((int) ($a['operation_run_id'] ?? 0)) <=> ((int) ($b['operation_run_id'] ?? 0)));
$this->info('Adapter run reconciliation');
$this->line('dry_run: '.($dryRun ? 'true' : 'false'));
$this->line('type: '.($type ?? '(all supported)'));
$this->line('tenant: '.($tenantId ? (string) $tenantId : '(all)'));
$this->line('older_than_minutes: '.$olderThanMinutes);
$this->line('limit: '.$limit);
$this->newLine();
$this->line('candidates: '.(int) ($result['candidates'] ?? 0));
$this->line('reconciled: '.(int) ($result['reconciled'] ?? 0));
$this->line('skipped: '.(int) ($result['skipped'] ?? 0));
$this->newLine();
if ($changes === []) {
$this->info('No changes.');
return self::SUCCESS;
}
$rows = [];
foreach ($changes as $change) {
$before = is_array($change['before'] ?? null) ? $change['before'] : [];
$after = is_array($change['after'] ?? null) ? $change['after'] : [];
$rows[] = [
'applied' => ($change['applied'] ?? false) ? 'yes' : 'no',
'operation_run_id' => (int) ($change['operation_run_id'] ?? 0),
'type' => (string) ($change['type'] ?? ''),
'source_id' => (int) ($change['restore_run_id'] ?? 0),
'before' => (string) (($before['status'] ?? '').'/'.($before['outcome'] ?? '')),
'after' => (string) (($after['status'] ?? '').'/'.($after['outcome'] ?? '')),
];
}
$this->table(
['applied', 'operation_run_id', 'type', 'source_id', 'before', 'after'],
$rows,
);
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('Reconciliation failed: '.$e->getMessage());
return self::FAILURE;
}
}
}

View File

@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
->firstOrFail();
}
return Tenant::current();
return Tenant::currentOrFail();
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TenantpilotDispatchDirectoryGroupsSync extends Command
{
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Dispatch scheduled directory group sync runs (idempotent per tenant minute-slot).';
public function handle(): int
{
if (! (bool) config('directory_groups.schedule.enabled', false)) {
return self::SUCCESS;
}
$now = CarbonImmutable::now('UTC');
$timeUtc = (string) config('directory_groups.schedule.time_utc', '02:00');
if (! $this->isDueAt($now, $timeUtc)) {
return self::SUCCESS;
}
if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) {
$this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.');
return self::SUCCESS;
}
$tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge(
(array) $this->option('tenant'),
(array) config('directory_groups.schedule.tenants', []),
))));
$tenants = $this->resolveTenants($tenantIdentifiers);
$selectionKey = 'groups-v1:all';
$slotKey = $now->format('YmdHi').'Z';
$created = 0;
$skipped = 0;
foreach ($tenants as $tenant) {
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
'status' => 'pending',
'initiator_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
if ($inserted === 1) {
$created++;
dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: $slotKey,
));
} else {
$skipped++;
}
}
$this->info(sprintf(
'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).',
$tenants->count(),
$created,
$skipped,
));
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
*/
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
{
$query = Tenant::activeQuery();
if ($tenantIdentifiers !== []) {
$query->where(function ($subQuery) use ($tenantIdentifiers) {
foreach ($tenantIdentifiers as $identifier) {
if (ctype_digit($identifier)) {
$subQuery->orWhereKey((int) $identifier);
continue;
}
$subQuery->orWhere('tenant_id', $identifier)
->orWhere('external_id', $identifier);
}
});
}
return $query->get();
}
private function isDueAt(CarbonImmutable $now, string $timeUtc): bool
{
if (! preg_match('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[0-5]\\d)$/', $timeUtc, $matches)) {
return false;
}
return (int) $matches['hour'] === (int) $now->format('H')
&& (int) $matches['minute'] === (int) $now->format('i');
}
}

View File

@ -7,7 +7,7 @@
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
@ -88,7 +88,7 @@ public function handle(): int
->where('tenant_id', $tenant->id)
->delete();
BulkOperationRun::query()
OperationRun::query()
->where('tenant_id', $tenant->id)
->delete();
@ -138,7 +138,7 @@ private function resolveTenants()
}
try {
return collect([Tenant::current()]);
return collect([Tenant::currentOrFail()]);
} catch (RuntimeException) {
return collect();
}
@ -152,7 +152,7 @@ private function countsForTenant(Tenant $tenant): array
return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'bulk_operation_runs' => BulkOperationRun::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(),
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),

View File

@ -0,0 +1,233 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
{--tenant=* : Limit to tenant_id/external_id}
{--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
public function handle(OperationRunService $operationRunService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
}
if ($tenantIdentifiers !== []) {
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
if ($tenantIds === []) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$query->whereIn('tenant_id', $tenantIds);
}
$reconciled = 0;
$skipped = 0;
$failed = 0;
foreach ($query->cursor() as $operationRun) {
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
if (! is_numeric($backupScheduleRunId)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
failures: [
[
'code' => 'backup_schedule_run.not_found',
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
],
],
);
}
$failed++;
continue;
}
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
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(
$operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$operationRun->forceFill([
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
])->save();
}
$reconciled++;
}
$this->info(sprintf(
'Reconciled %d run(s), skipped %d, failed %d.',
$reconciled,
$skipped,
$failed,
));
if ($dryRun) {
$this->comment('Dry-run: no changes written.');
}
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Filament\Clusters\Inventory;
use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Pages\Enums\SubNavigationPosition;
class InventoryCluster extends Cluster
{
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait ScopesGlobalSearchToTenant
{
/**
* The Eloquent relationship name used to scope records to the current tenant.
*/
protected static string $globalSearchTenantRelationship = 'tenant';
public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();
if ($panel?->hasTenancy()) {
$query->withoutGlobalScope($panel->getTenancyScopeName());
}
}
$tenant = Filament::getTenant();
if (! $tenant instanceof Model) {
return $query->whereRaw('1 = 0');
}
$user = auth()->user();
if (! $user || ! method_exists($user, 'canAccessTenant') || ! $user->canAccessTenant($tenant)) {
return $query->whereRaw('1 = 0');
}
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\Login as BaseLogin;
class Login extends BaseLogin
{
protected string $view = 'filament.pages.auth.login';
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class BreakGlassRecovery extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Break-glass recovery';
protected static ?int $navigationSort = 999;
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.break-glass-recovery';
public static function canAccess(): bool
{
return false;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Schema;
class ChooseTenant extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-tenant';
protected static ?string $title = 'Choose tenant';
protected string $view = 'filament.pages.choose-tenant';
/**
* @return Collection<int, Tenant>
*/
public function getTenants(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0')->get();
}
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
if ($tenants instanceof Collection) {
return $tenants;
}
return collect($tenants);
}
public function selectTenant(int $tenantId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
return;
}
if (! Schema::hasTable('user_tenant_preferences')) {
return;
}
UserTenantPreference::query()->updateOrCreate(
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
['last_used_at' => now()]
);
}
}

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ChooseWorkspace extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-workspace';
protected static ?string $title = 'Choose workspace';
protected string $view = 'filament.pages.choose-workspace';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->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)),
];
}
/**
* @return Collection<int, Workspace>
*/
public function getWorkspaces(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Workspace::query()->whereRaw('1 = 0')->get();
}
return Workspace::query()
->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->orderBy('name')
->get();
}
public function selectWorkspace(int $workspaceId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
private function redirectAfterWorkspaceSelected(User $user): string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return self::getUrl();
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(tenant: $tenant);
}
}
return ChooseTenant::getUrl();
}
}

View File

@ -0,0 +1,284 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class DriftLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
protected static string|UnitEnum|null $navigationGroup = 'Drift';
protected static ?string $navigationLabel = 'Drift';
protected string $view = 'filament.pages.drift-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $scopeKey = null;
public ?int $baselineRunId = null;
public ?int $currentRunId = null;
public ?string $baselineFinishedAt = null;
public ?string $currentFinishedAt = null;
public ?int $operationRunId = null;
/** @var array<string, int>|null */
public ?array $statusCounts = null;
public static function canAccess(): bool
{
return FindingResource::canAccess();
}
public function mount(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
$latestSuccessful = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('status', InventorySyncRun::STATUS_SUCCESS)
->whereNotNull('finished_at')
->orderByDesc('finished_at')
->first();
if (! $latestSuccessful instanceof InventorySyncRun) {
$this->state = 'blocked';
$this->message = 'No successful inventory runs found yet.';
return;
}
$scopeKey = (string) $latestSuccessful->selection_hash;
$this->scopeKey = $scopeKey;
$selector = app(DriftRunSelector::class);
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
if ($comparison === null) {
$this->state = 'blocked';
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
return;
}
$baseline = $comparison['baseline'];
$current = $comparison['current'];
$this->baselineRunId = (int) $baseline->getKey();
$this->currentRunId = (int) $current->getKey();
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
$existingOperationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
->where('context->scope_key', $scopeKey)
->where('context->baseline_run_id', (int) $baseline->getKey())
->where('context->current_run_id', (int) $current->getKey())
->latest('id')
->first();
if ($existingOperationRun instanceof OperationRun) {
$this->operationRunId = (int) $existingOperationRun->getKey();
}
$exists = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->exists();
if ($exists) {
$this->state = 'ready';
$newCount = (int) Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW)
->count();
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
return;
}
$existingOperationRun?->refresh();
if ($existingOperationRun instanceof OperationRun
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
) {
$this->state = 'generating';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
if ($existingOperationRun instanceof OperationRun
&& $existingOperationRun->status === 'completed'
) {
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
$created = (int) ($counts['created'] ?? 0);
if ($existingOperationRun->outcome === 'failed') {
$this->state = 'error';
$this->message = 'Drift generation failed for this comparison. See the run for details.';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
if ($created === 0) {
$this->state = 'ready';
$this->statusCounts = [Finding::STATUS_NEW => 0];
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
$this->state = 'blocked';
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromQuery([
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->enqueueBulkOperation(
tenant: $tenant,
type: 'drift.generate',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
emitQueuedNotification: false,
);
$this->operationRunId = (int) $opRun->getKey();
$this->state = 'generating';
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title('Drift generation already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}
public function getFindingsUrl(): string
{
return FindingResource::getUrl('index', tenant: Tenant::current());
}
public function getBaselineRunUrl(): ?string
{
if (! is_int($this->baselineRunId)) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
}
public function getCurrentRunUrl(): ?string
{
if (! is_int($this->currentRunId)) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
}
public function getOperationRunUrl(): ?string
{
if (! is_int($this->operationRunId)) {
return null;
}
return OperationRunLinks::view($this->operationRunId, Tenant::current());
}
}

View File

@ -2,7 +2,10 @@
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
@ -11,12 +14,23 @@ class InventoryCoverage extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
protected static ?int $navigationSort = 3;
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Coverage';
protected static ?string $cluster = InventoryCluster::class;
protected string $view = 'filament.pages.inventory-coverage';
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
/**
* @var array<int, array<string, mixed>>
*/
@ -29,12 +43,9 @@ class InventoryCoverage extends Page
public function mount(): void
{
$policyTypes = config('tenantpilot.supported_policy_types', []);
$foundationTypes = config('tenantpilot.foundation_types', []);
$resolver = app(CoverageCapabilitiesResolver::class);
$this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : [])
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
->map(function (array $row) use ($resolver): array {
$type = (string) ($row['type'] ?? '');
@ -44,7 +55,7 @@ public function mount(): void
})
->all();
$this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : [])
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
->map(function (array $row): array {
return array_merge($row, [
'dependencies' => false,

View File

@ -2,234 +2,37 @@
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Enums\Size;
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 = 'Inventory';
protected static ?string $navigationLabel = 'Overview';
protected static ?string $cluster = InventoryCluster::class;
protected string $view = 'filament.pages.inventory-landing';
protected function getHeaderActions(): array
public function mount(): void
{
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
}
protected function getHeaderWidgets(): array
{
return [
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Select::make('policy_types')
->label('Policy types')
->multiple()
->searchable()
->preload()
->native(false)
->hintActions([
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
->label('Select all')
->link()
->size(Size::Small)
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
}),
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
->label('Clear')
->link()
->size(Size::Small)
->action(function () use ($component): void {
$component->state([]);
}),
])
->options(function (): array {
return collect(config('tenantpilot.supported_policy_types', []))
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
->mapWithKeys(function ($items, string $category): array {
$options = collect($items)
->mapWithKeys(function (array $meta): array {
$type = (string) $meta['type'];
$label = (string) ($meta['label'] ?? $type);
$platform = (string) ($meta['platform'] ?? 'all');
return [$type => "{$label}{$platform}"];
})
->all();
return [$category => $options];
})
->all();
})
->default([])
->dehydrated()
->required()
->rules([
'array',
'min:1',
new \App\Rules\SupportedPolicyTypesRule,
])
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->helperText('Include scope tags, assignment filters, and notification templates.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Toggle::make('include_dependencies')
->label('Include dependencies')
->helperText('Include dependency extraction where supported.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(),
])
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canSyncTenant(Tenant::current());
})
->action(function (array $data, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $user->canSyncTenant($tenant)) {
abort(403, 'Not allowed');
}
$requestedTenantId = $data['tenant_id'] ?? null;
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
Notification::make()
->title('Not allowed')
->danger()
->send();
abort(403, 'Not allowed');
}
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
if (array_key_exists('policy_types', $data)) {
$selectionPayload['policy_types'] = $data['policy_types'];
}
if (array_key_exists('include_foundations', $data)) {
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
}
if (array_key_exists('include_dependencies', $data)) {
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Sync already running')
->body('An inventory sync is already running for this tenant. Check the progress widget for status.')
->warning()
->send();
return;
}
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$bulkRun = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'inventory',
action: 'sync',
itemIds: $policyTypes,
totalItems: count($policyTypes),
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync started')
->body('Sync dispatched. Check the bottom-right progress widget for status.')
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->sendToDatabase($user)
->send();
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
bulkRunId: (int) $bulkRun->getKey(),
inventorySyncRunId: (int) $run->getKey(),
);
}),
InventoryKpiHeader::class,
];
}
public function getInventoryItemsUrl(): string
{
return InventoryItemResource::getUrl('index', tenant: Tenant::current());
}
public function getSyncRunsUrl(): string
{
return InventorySyncRunResource::getUrl('index', tenant: Tenant::current());
}
public function getCoverageUrl(): string
{
return InventoryCoverage::getUrl(tenant: Tenant::current());
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Filament\Pages\Monitoring;
use App\Models\OperationRun;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class Operations extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $title = 'Operations';
// Must be non-static
protected string $view = 'filament.pages.monitoring.operations';
public function table(Table $table): Table
{
return $table
->query(
OperationRun::query()
->where('tenant_id', Filament::getTenant()->id)
->latest('created_at')
)
->columns([
TextColumn::make('type')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Started'),
TextColumn::make('duration')
->getStateUsing(function (OperationRun $record) {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '-';
}),
])
->filters([
SelectFilter::make('outcome')
->options([
'succeeded' => 'Succeeded',
'partially_succeeded' => 'Partially Succeeded',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
'pending' => 'Pending',
]),
SelectFilter::make('type')
->options(
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
->distinct()
->pluck('type', 'type')
->toArray()
),
Filter::make('created_at')
->form([
DatePicker::make('created_from'),
DatePicker::make('created_until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['created_from'],
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['created_until'],
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
);
}),
])
->actions([
// View action handled by opening a modal or side-peek
]);
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class NoAccess extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'no-access';
protected static ?string $title = 'No access';
protected string $view = 'filament.pages.no-access';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->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)),
];
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
}
}

View File

@ -4,7 +4,11 @@
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
@ -19,7 +23,42 @@ public static function getLabel(): string
public static function canView(): bool
{
return true;
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$canRegisterInWorkspace = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
if ($canRegisterInWorkspace) {
return true;
}
}
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
if ($tenantIds->isEmpty()) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
}
return false;
}
public function form(Schema $schema): Schema
@ -68,14 +107,46 @@ public function form(Schema $schema): Schema
*/
protected function handleRegistration(array $data): Model
{
if (! static::canView()) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
$tenant = Tenant::create($data);
$user = auth()->user();
if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => TenantRole::Owner->value],
$tenant->getKey() => [
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'tenant_membership.bootstrap_assign',
context: [
'metadata' => [
'user_id' => (int) $user->getKey(),
'role' => 'owner',
'source' => 'manual',
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
return $tenant;

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations;
use Filament\Pages\Dashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
class TenantDashboard extends Dashboard
{
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
public function getWidgets(): array
{
return [
DashboardKpis::class,
NeedsAttention::class,
RecentDriftFindings::class,
RecentOperations::class,
];
}
public function getColumns(): int|array
{
return 2;
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantDiagnosticsService;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Pages\Page;
class TenantDiagnostics extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'diagnostics';
protected string $view = 'filament.pages.tenant-diagnostics';
public bool $missingOwner = false;
public bool $hasDuplicateMembershipsForCurrentUser = false;
public function mount(): void
{
$tenant = Tenant::current();
$tenantId = (int) $tenant->getKey();
$this->missingOwner = ! TenantMembership::query()
->where('tenant_id', $tenantId)
->where('role', 'owner')
->exists();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
->userHasDuplicateMemberships($tenant, $user);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('bootstrapOwner')
->label('Bootstrap owner')
->requiresConfirmation()
->action(fn () => $this->bootstrapOwner()),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->destructive()
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->missingOwner),
UiEnforcement::forAction(
Action::make('mergeDuplicateMemberships')
->label('Merge duplicate memberships')
->requiresConfirmation()
->action(fn () => $this->mergeDuplicateMemberships()),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->destructive()
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
];
}
public function bootstrapOwner(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
$this->mount();
}
public function mergeDuplicateMemberships(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
$this->mount();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ManagedTenantsLanding extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace;
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
}
/**
* @return Collection<int, Tenant>
*/
public function getTenants(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0')->get();
}
return $user->tenants()
->where('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->orderBy('name')
->get();
}
public function goToChooseTenant(): void
{
$this->redirect(ChooseTenant::getUrl());
}
public function openTenant(int $tenantId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@
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;
@ -19,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
@ -27,15 +29,10 @@ public function table(Table $table): Table
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_SUCCESS => 'success',
BackupScheduleRun::STATUS_PARTIAL => 'warning',
BackupScheduleRun::STATUS_RUNNING => 'primary',
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED,
BackupScheduleRun::STATUS_CANCELED => 'danger',
default => 'gray',
}),
->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 {

View File

@ -9,14 +9,24 @@
use App\Jobs\BulkBackupSetRestoreJob;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -37,6 +47,22 @@ class BackupSetResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function canCreate(): 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_SYNC);
}
public static function form(Schema $schema): Schema
{
return $schema
@ -53,7 +79,12 @@ public static function table(Table $table): Table
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('status')->badge(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items'),
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
@ -71,251 +102,356 @@ public static function table(Table $table): Table
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
ActionGroup::make([
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record) => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$record->restore();
$record->items()->withTrashed()->restore();
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->restore();
$record->items()->withTrashed()->restore();
Notification::make()
->title('Backup set restored')
->success()
->send();
}),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record) => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
Notification::make()
->title('Backup set archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record) => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->title('Backup set restored')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
return;
}
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set archived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
}),
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->send();
return;
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return $isOnlyTrashed;
})
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
if (! $tenant instanceof Tenant) {
return;
}
if ($count >= 10) {
Notification::make()
->title('Bulk archive started')
->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
BulkBackupSetDeleteJob::dispatch($run->id);
} else {
BulkBackupSetDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
$initiator = $user instanceof User ? $user : null;
if ($count >= 10) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
BulkBackupSetRestoreJob::dispatch($run->id);
} else {
BulkBackupSetRestoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
$initiator = $user instanceof User ? $user : null;
if ($count >= 10) {
Notification::make()
->title('Bulk force delete started')
->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
BulkBackupSetForceDeleteJob::dispatch($run->id);
} else {
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
]);
}
@ -325,7 +461,12 @@ public static function infolist(Schema $schema): Schema
return $schema
->schema([
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('status')->badge(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Infolists\Components\TextEntry::make('item_count')->label('Items'),
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -13,7 +15,7 @@ class ListBackupSets extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
];
}
}

View File

@ -3,8 +3,20 @@
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
use App\Filament\Resources\PolicyResource;
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Services\Intune\AuditLogger;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
@ -18,8 +30,210 @@ class BackupItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
protected $listeners = [
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
];
public function closeAddPoliciesModal(): void
{
$this->unmountAction();
}
public function table(Table $table): Table
{
$refreshTable = Actions\Action::make('refreshTable')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$this->resetTable();
});
$addPolicies = Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->tooltip('You do not have permission to add policies.')
->modalHeading('Add Policies')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (): View {
$backupSet = $this->getOwnerRecord();
return view('filament.modals.backup-set-policy-picker', [
'backupSetId' => $backupSet->getKey(),
]);
});
UiEnforcement::forAction($addPolicies)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to add policies.')
->apply();
$removeItem = Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(404);
}
$backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
});
UiEnforcement::forAction($removeItem)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to remove policies.')
->apply();
$bulkRemove = Actions\BulkAction::make('bulk_remove')
->label('Remove selected')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(404);
}
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) {
return;
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
});
UiEnforcement::forBulkAction($bulkRemove)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to remove policies.')
->apply();
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([
@ -36,21 +250,31 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
Tables\Columns\TextColumn::make('risk')
->label('Risk')
->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID')
->copyable(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('assignments')
->label('Assignments')
->badge()
@ -94,19 +318,8 @@ public function table(Table $table): Table
])
->filters([])
->headerActions([
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->modalHeading('Add Policies')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (): View {
$backupSet = $this->getOwnerRecord();
return view('filament.modals.backup-set-policy-picker', [
'backupSetId' => $backupSet->getKey(),
]);
}),
$refreshTable,
$addPolicies,
])
->actions([
Actions\ActionGroup::make([
@ -123,82 +336,12 @@ public function table(Table $table): Table
})
->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true),
Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record, AuditLogger $auditLogger) {
$record->delete();
if ($record->backupSet) {
$record->backupSet->update([
'item_count' => $record->backupSet->items()->count(),
]);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.item_removed',
resourceType: 'backup_set',
resourceId: (string) $record->backup_set_id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id]]
);
}
Notification::make()
->title('Policy removed from backup')
->success()
->send();
}),
$removeItem,
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
Actions\BulkActionGroup::make([
Actions\BulkAction::make('bulk_remove')
->label('Remove selected')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->action(function (Collection $records, AuditLogger $auditLogger) {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$records->each(fn (BackupItem $record) => $record->delete());
$backupSet->update([
'item_count' => $backupSet->items()->count(),
]);
$tenant = $records->first()?->tenant;
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->id,
status: 'success',
context: [
'metadata' => [
'removed_count' => $records->count(),
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
],
]
);
}
Notification::make()
->title('Policies removed from backup')
->success()
->send();
}),
$bulkRemove,
]),
]);
}

View File

@ -0,0 +1,225 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use BackedEnum;
use Filament\Actions;
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\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class EntraGroupResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroup::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Groups';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Group')
->schema([
TextEntry::make('display_name')->label('Name'),
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
TextEntry::make('type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
TextEntry::make('security_enabled')
->label('Security')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('mail_enabled')
->label('Mail')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Raw groupTypes')
->schema([
ViewEntry::make('group_types')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EntraGroup $record) => $record->group_types ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('display_name')
->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('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'),
])
->filters([
SelectFilter::make('stale')
->label('Stale')
->options([
'1' => 'Stale',
'0' => 'Fresh',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if ($value === null || $value === '') {
return $query;
}
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
if ((string) $value === '1') {
return $query->where(function (Builder $q) use ($cutoff): void {
$q->whereNull('last_seen_at')
->orWhere('last_seen_at', '<', $cutoff);
});
}
return $query->where('last_seen_at', '>=', $cutoff);
}),
SelectFilter::make('group_type')
->label('Type')
->options([
'security' => 'Security',
'microsoft365' => 'Microsoft 365',
'mail' => 'Mail-enabled',
'unknown' => 'Unknown',
])
->query(function (Builder $query, array $data): Builder {
$value = (string) ($data['value'] ?? '');
if ($value === '') {
return $query;
}
return match ($value) {
'microsoft365' => $query->whereJsonContains('group_types', 'Unified'),
'security' => $query
->where('security_enabled', true)
->where(function (Builder $q): void {
$q->whereNull('group_types')
->orWhereJsonDoesntContain('group_types', 'Unified');
}),
'mail' => $query
->where('mail_enabled', true)
->where(function (Builder $q): void {
$q->whereNull('group_types')
->orWhereJsonDoesntContain('group_types', 'Unified');
}),
'unknown' => $query
->where(function (Builder $q): void {
$q->whereNull('group_types')
->orWhereJsonDoesntContain('group_types', 'Unified');
})
->where('security_enabled', false)
->where('mail_enabled', false),
default => $query,
};
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListEntraGroups::route('/'),
'view' => Pages\ViewEntraGroup::route('/{record}'),
];
}
private static function groupType(EntraGroup $record): string
{
$groupTypes = $record->group_types;
if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) {
return 'microsoft365';
}
if ($record->security_enabled) {
return 'security';
}
if ($record->mail_enabled) {
return 'mail';
}
return 'unknown';
}
private static function groupTypeLabel(string $type): string
{
return match ($type) {
'microsoft365' => 'Microsoft 365',
'security' => 'Security',
'mail' => 'Mail-enabled',
default => 'Unknown',
};
}
private static function groupTypeColor(string $type): string
{
return match ($type) {
'microsoft365' => 'info',
'security' => 'success',
'mail' => 'warning',
default => 'gray',
};
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords
{
protected static string $resource = EntraGroupResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('view_group_sync_runs')
->label('Group Sync Runs')
->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()),
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();
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
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)),
])
->send();
return;
}
// ----------------------------------------------
$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(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
operationRun: $opRun
));
Notification::make()
->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync groups.')
->apply(),
];
}
}

View File

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

View File

@ -0,0 +1,154 @@
<?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 BackedEnum;
use Filament\Actions;
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 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(),
])
->actions([
Actions\ViewAction::make(),
])
->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

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

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

@ -0,0 +1,445 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\FindingResource\Pages;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
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\Arr;
use Illuminate\Support\Collection;
use UnitEnum;
class FindingResource extends Resource
{
protected static ?string $model = Finding::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Drift';
protected static ?string $navigationLabel = 'Findings';
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::TENANT_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::TENANT_VIEW, $tenant)) {
return false;
}
if ($record instanceof Finding) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Finding')
->schema([
TextEntry::make('finding_type')->badge()->label('Type'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextEntry::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
TextEntry::make('scope_key')->label('Scope')->copyable(),
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
TextEntry::make('subject_type')->label('Subject type'),
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
TextEntry::make('baseline_run_id')
->label('Baseline run')
->url(fn (Finding $record): ?string => $record->baseline_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
: null)
->openUrlInNewTab(),
TextEntry::make('current_run_id')
->label('Current run')
->url(fn (Finding $record): ?string => $record->current_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
: null)
->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('created_at')->label('Created')->dateTime(),
])
->columns(2)
->columnSpanFull(),
Section::make('Diff')
->schema([
ViewEntry::make('settings_diff')
->label('')
->view('filament.infolists.entries.normalized-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
if (($addedCount + $removedCount + $changedCount) === 0) {
Arr::set(
$diff,
'summary.message',
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
);
}
return $diff;
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
->columnSpanFull(),
ViewEntry::make('scope_tags_diff')
->label('')
->view('filament.infolists.entries.scope-tags-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
->columnSpanFull(),
ViewEntry::make('assignments_diff')
->label('')
->view('filament.infolists.entries.assignments-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
->columnSpanFull(),
])
->collapsed()
->columnSpanFull(),
Section::make('Evidence (Sanitized)')
->schema([
ViewEntry::make('evidence_jsonb')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
Tables\Columns\TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
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_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
Finding::STATUS_NEW => 'New',
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
])
->default(Finding::STATUS_NEW),
Tables\Filters\SelectFilter::make('finding_type')
->options([
Finding::FINDING_TYPE_DRIFT => 'Drift',
])
->default(Finding::FINDING_TYPE_DRIFT),
Tables\Filters\Filter::make('scope_key')
->form([
TextInput::make('scope_key')
->label('Scope key')
->placeholder('Inventory selection hash')
->maxLength(255),
])
->query(function (Builder $query, array $data): Builder {
$scopeKey = $data['scope_key'] ?? null;
if (! is_string($scopeKey) || $scopeKey === '') {
return $query;
}
return $query->where('scope_key', $scopeKey);
}),
Tables\Filters\Filter::make('run_ids')
->label('Run IDs')
->form([
TextInput::make('baseline_run_id')
->label('Baseline run id')
->numeric(),
TextInput::make('current_run_id')
->label('Current run id')
->numeric(),
])
->query(function (Builder $query, array $data): Builder {
$baselineRunId = $data['baseline_run_id'] ?? null;
if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId);
}
$currentRunId = $data['current_run_id'] ?? null;
if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId);
}
return $query;
}),
])
->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(),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('acknowledge_selected')
->label('Acknowledge selected')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->action(function (Collection $records): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$acknowledgedCount = 0;
$skippedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if ($record->status !== Finding::STATUS_NEW) {
$skippedCount++;
continue;
}
$record->acknowledge($user);
$acknowledgedCount++;
}
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
Notification::make()
->title('Bulk acknowledge completed')
->body($body)
->success()
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function getPages(): array
{
return [
'index' => Pages\ListFindings::route('/'),
'view' => Pages\ViewFinding::route('/{record}'),
];
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
class ListFindings extends ListRecords
{
protected static string $resource = FindingResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('acknowledge_all_matching')
->label('Acknowledge all matching')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->modalDescription(function (): string {
$count = $this->getAllMatchingCount();
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
})
->form(function (): array {
$count = $this->getAllMatchingCount();
if ($count <= 100) {
return [];
}
return [
TextInput::make('confirmation')
->label('Type ACKNOWLEDGE to confirm')
->required()
->in(['ACKNOWLEDGE'])
->validationMessages([
'in' => 'Please type ACKNOWLEDGE to confirm.',
]),
];
})
->action(function (array $data): void {
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
if ($count === 0) {
Notification::make()
->title('No matching findings')
->body('There are no new findings matching the current filters.')
->warning()
->send();
return;
}
$updated = $query->update([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => auth()->id(),
]);
$this->deselectAllTableRecords();
$this->resetPage();
Notification::make()
->title('Bulk acknowledge completed')
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
protected function buildAllMatchingQuery(): Builder
{
$query = Finding::query();
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
if (! is_numeric($tenantId)) {
return $query->whereRaw('1 = 0');
}
$query->where('tenant_id', (int) $tenantId);
$query->where('status', Finding::STATUS_NEW);
$findingType = $this->getFindingTypeFilterValue();
if (is_string($findingType) && $findingType !== '') {
$query->where('finding_type', $findingType);
}
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
if (is_string($scopeKey) && $scopeKey !== '') {
$query->where('scope_key', $scopeKey);
}
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId);
}
$currentRunId = Arr::get($runIdsState, 'current_run_id');
if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId);
}
return $query;
}
protected function getAllMatchingCount(): int
{
return (int) $this->buildAllMatchingQuery()->count();
}
protected function getStatusFilterValue(): string
{
$state = $this->getTableFilterState('status') ?? [];
$value = Arr::get($state, 'value');
return is_string($value) && $value !== ''
? $value
: Finding::STATUS_NEW;
}
protected function getFindingTypeFilterValue(): string
{
$state = $this->getTableFilterState('finding_type') ?? [];
$value = Arr::get($state, 'value');
return is_string($value) && $value !== ''
? $value
: Finding::FINDING_TYPE_DRIFT;
}
}

View File

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

View File

@ -2,12 +2,21 @@
namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -18,16 +27,62 @@
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class InventoryItemResource extends Resource
{
protected static ?string $model = InventoryItem::class;
protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 1;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
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;
}
$capabilityResolver = app(CapabilityResolver::class);
return $capabilityResolver->isMember($user, $tenant)
&& $capabilityResolver->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;
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
return false;
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
return false;
}
if ($record instanceof InventoryItem) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function form(Schema $schema): Schema
{
return $schema;
@ -43,12 +98,18 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('policy_type')
->label('Type')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
TextEntry::make('category')
->badge()
->state(fn (InventoryItem $record): string => $record->category
?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')),
TextEntry::make('platform')->badge(),
->state(fn (InventoryItem $record): ?string => $record->category
?: (static::typeMeta($record->policy_type)['category'] ?? null))
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
TextEntry::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
TextEntry::make('last_seen_run_id')
@ -64,11 +125,19 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('support_restore')
->label('Restore')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
TextEntry::make('support_risk')
->label('Risk')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'),
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
])
->columns(2)
->columnSpanFull(),
@ -138,17 +207,52 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('category')
->badge(),
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
Tables\Columns\TextColumn::make('platform')
->badge(),
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status')
->label('Run')
->badge(),
->badge()
->formatStateUsing(function (?string $state): string {
if (! filled($state)) {
return '—';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
})
->color(function (?string $state): string {
if (! filled($state)) {
return 'gray';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
})
->icon(function (?string $state): ?string {
if (! filled($state)) {
return null;
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
})
->iconColor(function (?string $state): ?string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
return $spec->iconColor ?? $spec->color;
}),
])
->filters([
Tables\Filters\SelectFilter::make('policy_type')
@ -166,7 +270,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
@ -190,8 +294,7 @@ private static function typeMeta(?string $type): array
return [];
}
return collect(static::allTypeMeta())
->firstWhere('type', $type) ?? [];
return InventoryPolicyTypeMeta::metaFor($type);
}
/**
@ -199,12 +302,6 @@ private static function typeMeta(?string $type): array
*/
private static function allTypeMeta(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
$foundations = config('tenantpilot.foundation_types', []);
return array_merge(
is_array($supported) ? $supported : [],
is_array($foundations) ? $foundations : [],
);
return InventoryPolicyTypeMeta::all();
}
}

View File

@ -3,9 +3,249 @@
namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Size;
class ListInventoryItems extends ListRecords
{
protected static string $resource = InventoryItemResource::class;
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Select::make('policy_types')
->label('Policy types')
->multiple()
->searchable()
->preload()
->native(false)
->hintActions([
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
->label('Select all')
->link()
->size(Size::Small)
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
}),
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
->label('Clear')
->link()
->size(Size::Small)
->action(function () use ($component): void {
$component->state([]);
}),
])
->options(function (): array {
return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
->mapWithKeys(function ($items, string $category): array {
$options = collect($items)
->mapWithKeys(function (array $meta): array {
$type = (string) $meta['type'];
$label = (string) ($meta['label'] ?? $type);
$platform = (string) ($meta['platform'] ?? 'all');
return [$type => "{$label}{$platform}"];
})
->all();
return [$category => $options];
})
->all();
})
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->helperText('Include scope tags, assignment filters, and notification templates.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Toggle::make('include_dependencies')
->label('Include dependencies')
->helperText('Include dependency extraction where supported.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(),
])
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant);
})
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$requestedTenantId = $data['tenant_id'] ?? null;
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
Notification::make()
->title('Not allowed')
->danger()
->send();
return;
}
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
if (array_key_exists('policy_types', $data)) {
$selectionPayload['policy_types'] = $data['policy_types'];
}
if (array_key_exists('include_foundations', $data)) {
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
}
if (array_key_exists('include_dependencies', $data)) {
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'inventory.sync',
inputs: $computed['selection'],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Inventory 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)),
])
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
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(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
operationRun: $opRun
);
});
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -2,9 +2,16 @@
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 BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -15,16 +22,66 @@
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 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;
@ -34,6 +91,18 @@ 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')
@ -41,14 +110,23 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'),
TextEntry::make('status')
->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
->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(),
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(),
@ -91,7 +169,10 @@ public static function table(Table $table): Table
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
->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()
@ -116,7 +197,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with('user')
@ -130,16 +211,4 @@ public static function getPages(): array
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
];
}
private static function statusColor(?string $status): string
{
return match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'success',
InventorySyncRun::STATUS_PARTIAL => 'warning',
InventorySyncRun::STATUS_FAILED => 'danger',
InventorySyncRun::STATUS_SKIPPED => 'gray',
InventorySyncRun::STATUS_RUNNING => 'info',
default => 'gray',
};
}
}

View File

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

@ -0,0 +1,334 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportViewer;
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\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
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 OperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = OperationRun::class;
protected static ?string $slug = 'operations';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations';
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with('user')
->latest('id')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Run')
->schema([
TextEntry::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextEntry::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextEntry::make('initiator_name')->label('Initiator'),
TextEntry::make('target_scope_display')
->label('Target')
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
->columnSpanFull(),
TextEntry::make('elapsed')
->label('Elapsed')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
TextEntry::make('expected_duration')
->label('Expected')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
TextEntry::make('stuck_guidance')
->label('')
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('started_at')->dateTime()->placeholder('—'),
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
])
->extraAttributes([
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
])
->poll(function (OperationRun $record, $livewire): ?string {
if (($livewire->opsUxIsTabHidden ?? false) === true) {
return null;
}
if (filled($livewire->mountedActions ?? null)) {
return null;
}
return RunDetailPolling::interval($record);
})
->columns(2)
->columnSpanFull(),
Section::make('Counts')
->schema([
ViewEntry::make('summary_counts')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
->columnSpanFull(),
Section::make('Failures')
->schema([
ViewEntry::make('failure_summary')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Verification report')
->schema([
ViewEntry::make('verification_report')
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(function (OperationRun $record): array {
$context = $record->context ?? [];
$context = is_array($context) ? $context : [];
if (array_key_exists('verification_report', $context)) {
$context['verification_report'] = [
'redacted' => true,
'note' => 'Rendered in the Verification report section.',
];
}
return $context;
})
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
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('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->label('Started')
->since()
->sortable(),
Tables\Columns\TextColumn::make('duration')
->getStateUsing(function (OperationRun $record): string {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '—';
}),
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)),
])
->filters([
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->select('type')
->distinct()
->orderBy('type')
->pluck('type', 'type')
->all();
}),
Tables\Filters\SelectFilter::make('status')
->options([
OperationRunStatus::Queued->value => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => 'Completed',
]),
Tables\Filters\SelectFilter::make('outcome')
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
->orderBy('initiator_name')
->pluck('initiator_name', 'initiator_name')
->all();
})
->searchable(),
Tables\Filters\Filter::make('created_at')
->label('Created')
->form([
DatePicker::make('created_from')
->label('From'),
DatePicker::make('created_until')
->label('Until'),
])
->default(fn (): array => [
'created_from' => now()->subDays(30),
'created_until' => now(),
])
->query(function (Builder $query, array $data): Builder {
$from = $data['created_from'] ?? null;
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
$until = $data['created_until'] ?? null;
if ($until) {
$query->whereDate('created_at', '<=', $until);
}
return $query;
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListOperationRuns::route('/'),
'view' => Pages\ViewOperationRun::route('/{record}'),
];
}
private static function targetScopeDisplay(OperationRun $record): ?string
{
$context = is_array($record->context) ? $record->context : [];
$targetScope = $context['target_scope'] ?? null;
if (! is_array($targetScope)) {
return null;
}
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$directoryContextId = $targetScope['directory_context_id'] ?? null;
$entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null;
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null;
$directoryContextId = match (true) {
is_string($directoryContextId) => trim($directoryContextId),
is_int($directoryContextId) => (string) $directoryContextId,
default => null,
};
$entra = null;
if ($entraTenantName !== null && $entraTenantName !== '') {
$entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName;
} elseif ($entraTenantId !== null && $entraTenantId !== '') {
$entra = $entraTenantId;
}
$parts = array_values(array_filter([
$entra,
$directoryContextId ? "directory_context_id: {$directoryContextId}" : null,
], fn (?string $value): bool => $value !== null && $value !== ''));
return $parts !== [] ? implode(' · ', $parts) : null;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
class ListOperationRuns extends ListRecords
{
protected static string $resource = OperationRunResource::class;
protected function getHeaderWidgets(): array
{
return [
OperationsKpiHeader::class,
];
}
/**
* @return array<string, Tab>
*/
public function getTabs(): array
{
return [
'all' => Tab::make(),
'active' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])),
'succeeded' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value)),
'partial' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
'failed' => Tab::make()
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)),
];
}
protected function getTablePollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Str;
class ViewOperationRun extends ViewRecord
{
protected static string $resource = OperationRunResource::class;
public bool $opsUxIsTabHidden = false;
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return [];
}
/** @var OperationRun $run */
$run = $this->getRecord();
$related = OperationRunLinks::related($run, $tenant);
$actions = [];
foreach ($related as $label => $url) {
$actions[] = Actions\Action::make(Str::slug($label, '_'))
->label($label)
->url($url)
->openUrlInNewTab();
}
if (empty($actions)) {
return [];
}
return [
Actions\ActionGroup::make($actions)
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray'),
];
}
}

View File

@ -6,12 +6,23 @@
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicySyncJob;
use App\Jobs\BulkPolicyUnignoreJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Models\User;
use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -61,8 +72,11 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('latest_snapshot_mode')
->label('Snapshot')
->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode))
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata_only' : 'full')
->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record);
@ -231,17 +245,28 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('category')
->label('Category')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
->sortable(),
Tables\Columns\TextColumn::make('settings_status')
->label('Settings')
@ -338,243 +363,538 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
Actions\Action::make('ignore')
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->action(function (Policy $record) {
$record->ignore();
UiEnforcement::forTableAction(
Actions\Action::make('ignore')
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->action(function (Policy $record): void {
$record->ignore();
Notification::make()
->title('Policy ignored')
->success()
->send();
}),
Actions\Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at !== null)
->action(function (Policy $record) {
$record->unignore();
Notification::make()
->title('Policy ignored')
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to ignore policies.')
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
->action(function (Policy $record): void {
$record->unignore();
Notification::make()
->title('Policy restored')
->success()
->send();
}),
Actions\Action::make('sync')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->action(function (Policy $record) {
$tenant = Tenant::current();
$user = auth()->user();
Notification::make()
->title('Policy restored')
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to restore policies.')
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('sync')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->action(function (Policy $record, HasTable $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
if (! $tenant instanceof Tenant) {
abort(404);
}
BulkPolicySyncJob::dispatchSync($run->id);
}),
Actions\Action::make('export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record) => $record->ignored_at === null)
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync_one',
inputs: [
'scope' => 'one',
'policy_id' => (int) $record->getKey(),
],
initiator: $user
);
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}),
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_SYNC)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
$ids = [(int) $record->getKey()];
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.export',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
BulkPolicyExportJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'backup_name' => (string) $data['backup_name'],
'policy_count' => 1,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Ignore Policies')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Ignore Policies')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return $value === 'ignored';
})
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return [];
})
->action(function (Collection $records): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
BulkPolicyDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'policy_count' => $count,
],
emitQueuedNotification: false,
);
if ($count >= 20) {
Notification::make()
->title('Bulk delete started')
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
->title('Policy delete queued')
->body("Queued deletion for {$count} policies.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($user)
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
BulkPolicyDeleteJob::dispatch($run->id);
} else {
BulkPolicyDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
BulkAction::make('bulk_restore')
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return ! in_array($value, [null, 'ignored'], true);
})
->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return ! in_array($value, [null, 'ignored'], true);
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
abort(404);
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
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)
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.unignore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
if ($count >= 20) {
BulkPolicyUnignoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkPolicyUnignoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'policy_count' => $count,
],
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);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
BulkPolicyUnignoreJob::dispatch($run->id);
} else {
BulkPolicyUnignoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_sync')
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
BulkAction::make('bulk_sync')
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
return $value === 'ignored';
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
abort(404);
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
if ($count >= 20) {
Notification::make()
->title('Bulk sync started')
->body("Syncing {$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)
$ids = $records
->pluck('id')
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'subset',
'policy_ids' => $ids,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_SYNC)
->apply(),
BulkPolicySyncJob::dispatch($run->id);
} else {
BulkPolicySyncJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
BulkAction::make('bulk_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
abort(404);
}
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
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)
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.export',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
if ($count >= 20) {
BulkPolicyExportJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
return;
}
BulkPolicyExportJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'backup_name' => (string) $data['backup_name'],
'policy_count' => $count,
],
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)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
} else {
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}
})
->deselectRecordsAfterCompletion(),
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -3,8 +3,15 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Services\Intune\PolicySyncService;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
@ -16,59 +23,70 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->action(function () {
try {
UiEnforcement::forAction(
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
/** @var PolicySyncService $service */
$service = app(PolicySyncService::class);
$result = $service->syncPoliciesWithReport($tenant);
$syncedCount = count($result['synced'] ?? []);
$failureCount = count($result['failures'] ?? []);
$body = $syncedCount.' policies synced';
if ($failureCount > 0) {
$first = $result['failures'][0] ?? [];
$firstType = $first['policy_type'] ?? 'unknown';
$firstStatus = $first['status'] ?? null;
$firstErrorMessage = null;
$firstErrors = $first['errors'] ?? null;
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
}
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$suffix .= ' - '.trim($firstErrorMessage);
}
$body .= " ({$failureCount} failed; {$suffix})";
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
Notification::make()
->title('Policy sync completed')
->body($body)
->success()
->sendToDatabase(auth()->user())
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
} catch (\Throwable $e) {
Notification::make()
->title('Policy sync failed')
->body($e->getMessage())
->danger()
->sendToDatabase(auth()->user())
->send();
}
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->destructive()
->apply(),
];
}
}

View File

@ -3,12 +3,17 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource;
use App\Services\Intune\VersionService;
use App\Jobs\CapturePolicySnapshotJob;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width;
use Illuminate\Support\Str;
class ViewPolicy extends ViewRecord
{
@ -23,7 +28,7 @@ protected function getActions(): array
->label('Capture snapshot')
->requiresConfirmation()
->modalHeading('Capture snapshot now')
->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.')
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
->form([
Forms\Components\Checkbox::make('include_assignments')
->label('Include assignments')
@ -37,51 +42,78 @@ protected function getActions(): array
->action(function (array $data) {
$policy = $this->record;
try {
$tenant = $policy->tenant;
$tenant = $policy->tenant;
$user = auth()->user();
if (! $tenant) {
Notification::make()
->title('Policy has no tenant associated.')
->danger()
->send();
return;
}
$version = app(VersionService::class)->captureFromGraph(
tenant: $tenant,
policy: $policy,
createdBy: auth()->user()?->email ?? null,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
);
if (($version->metadata['source'] ?? null) === 'metadata_only') {
$status = $version->metadata['original_status'] ?? null;
Notification::make()
->title('Snapshot captured (metadata only)')
->body(sprintf(
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
$status ?? 'an error'
))
->warning()
->send();
} else {
Notification::make()
->title('Snapshot captured successfully.')
->success()
->send();
}
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
} catch (\Throwable $e) {
if (! $tenant || ! $user) {
Notification::make()
->title('Failed to capture snapshot: '.$e->getMessage())
->title('Missing tenant or user context.')
->danger()
->send();
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.capture_snapshot',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void {
CapturePolicySnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyId: (int) $policy->getKey(),
includeAssignments: (bool) ($data['include_assignments'] ?? false),
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
operationRun: $operationRun,
context: [],
);
},
initiator: $user,
extraContext: [
'policy_id' => (int) $policy->getKey(),
'include_assignments' => (bool) ($data['include_assignments'] ?? false),
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false),
],
emitQueuedNotification: false,
);
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title('Snapshot already in progress')
->body('An active run already exists for this policy. Opening run details.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
$this->redirect(OperationRunLinks::view($opRun, $tenant));
return;
}
OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$this->redirect(OperationRunLinks::view($opRun, $tenant));
})
->color('primary'),
];

View File

@ -5,7 +5,14 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
@ -19,68 +26,132 @@ class VersionsRelationManager extends RelationManager
public function table(Table $table): Table
{
$restoreToIntune = Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()
->title('Missing tenant or user context.')
->danger()
->send();
return;
}
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: $user->email,
actorName: $user->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
});
UiEnforcement::forAction($restoreToIntune)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
$restoreToIntune
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true;
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
})
->tooltip(function (PolicyVersion $record): ?string {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return UiTooltips::INSUFFICIENT_PERMISSION;
}
return null;
});
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')->badge()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->filters([])
->headerActions([])
->actions([
Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
}),
$restoreToIntune,
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),

View File

@ -10,10 +10,19 @@
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions;
@ -48,8 +57,14 @@ public static function infolist(Schema $schema): Schema
->schema([
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
Infolists\Components\TextEntry::make('version_number')->label('Version'),
Infolists\Components\TextEntry::make('policy_type'),
Infolists\Components\TextEntry::make('platform'),
Infolists\Components\TextEntry::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Infolists\Components\TextEntry::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Tabs::make()
@ -169,12 +184,306 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.prune',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
BulkPolicyVersionPruneJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
retentionDays: $retentionDays,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
'retention_days' => $retentionDays,
],
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')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkPruneVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkRestoreVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
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')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $table
->columns([
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('policy_type')->badge(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
])
@ -188,343 +497,325 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
(function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->visible(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
return;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$policy = $record->policy;
return $resolver->isMember($user, $tenant);
})
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true;
}
if (! $policy) {
Notification::make()
->title('Policy could not be found for this version')
->danger()
->send();
$tenant = Tenant::current();
$user = auth()->user();
return;
}
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
})
->tooltip(function (PolicyVersion $record): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return 'You do not have permission to create restore runs.';
}
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
return null;
})
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
if (($record->metadata['source'] ?? null) === 'metadata_only') {
Notification::make()
->title('Restore disabled for metadata-only snapshot')
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
->warning()
->send();
return;
}
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
$policy = $record->policy;
if (! $policy) {
Notification::make()
->title('Policy could not be found for this version')
->danger()
->send();
return;
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
];
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
],
]);
'policy_version_id' => $record->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
'payload' => $record->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $record->assignments,
]);
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
return redirect()->to(RestoreRunResource::getUrl('create', [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
});
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
];
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => ! $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$record->delete();
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $record->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
'payload' => $record->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $record->assignments,
]);
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
return redirect()->to(RestoreRunResource::getUrl('create', [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
}),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => ! $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$record->delete();
Notification::make()
->title('Policy version archived')
->success()
->send();
});
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
Notification::make()
->title('Policy version archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.force_deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
$record->forceDelete();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
Notification::make()
->title('Policy version permanently deleted')
->success()
->send();
}),
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.force_deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$record->restore();
$record->forceDelete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.restored',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Notification::make()
->title('Policy version permanently deleted')
->success()
->send();
});
Notification::make()
->title('Policy version restored')
->success()
->send();
}),
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
$record->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.restored',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Notification::make()
->title('Policy version restored')
->success()
->send();
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk prune started')
->body("Pruning {$count} policy versions 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();
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
} else {
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policy versions 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();
BulkPolicyVersionRestoreJob::dispatch($run->id);
} else {
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk force delete started')
->body("Force deleting {$count} policy versions 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();
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
} else {
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
$bulkPruneVersions,
$bulkRestoreVersions,
$bulkForceDeleteVersions,
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -0,0 +1,651 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class ProviderConnectionResource extends Resource
{
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false;
protected static ?string $model = ProviderConnection::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static string|UnitEnum|null $navigationGroup = 'Providers';
protected static ?string $navigationLabel = 'Connections';
protected static ?string $recordTitleAttribute = 'display_name';
protected static function hasTenantCapability(string $capability): 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, $capability);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('display_name')
->label('Display name')
->required()
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->required()
->maxLength(255)
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Toggle::make('is_default')
->label('Default connection')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->helperText('Exactly one default connection is required per tenant/provider.'),
TextInput::make('status')
->label('Status')
->disabled()
->dehydrated(false),
TextInput::make('health_status')
->label('Health')
->disabled()
->dehydrated(false),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->defaultSort('display_name')
->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
Tables\Columns\TextColumn::make('health_status')
->label('Health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
])
->filters([
SelectFilter::make('status')
->label('Status')
->options([
'connected' => 'Connected',
'needs_consent' => 'Needs consent',
'error' => 'Error',
'disabled' => 'Disabled',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('status', $value);
}),
SelectFilter::make('health_status')
->label('Health')
->options([
'ok' => 'OK',
'degraded' => 'Degraded',
'down' => 'Down',
'unknown' => 'Unknown',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('health_status', $value);
}),
])
->actions([
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\EditAction::make()
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
initiator: $user,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A connection check is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Connection check queued')
->body('Health check was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'inventory.sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('An inventory sync is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'compliance.snapshot',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderComplianceSnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A compliance snapshot is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$record->makeDefault();
$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.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Default connection updated')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->form([
TextInput::make('client_id')
->label('Client ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$credentials->upsertClientSecretCredential(
connection: $record,
clientId: (string) $data['client_id'],
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,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Credentials updated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
]);
$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.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
'to_status' => $status,
'credentials_present' => $hadCredentials,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->warning()
->send();
return;
}
Notification::make()
->title('Provider connection enabled')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$previousStatus = (string) $record->status;
$record->update([
'status' => 'disabled',
]);
$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.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Provider connection disabled')
->warning()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListProviderConnections::route('/'),
'create' => Pages\CreateProviderConnection::route('/create'),
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateProviderConnection extends CreateRecord
{
protected static string $resource = ProviderConnectionResource::class;
protected bool $shouldMakeDefault = false;
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenant = Tenant::current();
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
return [
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'],
'is_default' => false,
];
}
protected function afterCreate(): void
{
$tenant = Tenant::current();
$record = $this->getRecord();
$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;
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
$hasDefault = $tenant->providerConnections()
->where('provider', $record->provider)
->where('is_default', true)
->exists();
if ($this->shouldMakeDefault || ! $hasDefault) {
$record->makeDefault();
}
Notification::make()
->title('Provider connection created')
->success()
->send();
}
}

View File

@ -0,0 +1,722 @@
<?php
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
class EditProviderConnection extends EditRecord
{
protected static string $resource = ProviderConnectionResource::class;
protected bool $shouldMakeDefault = false;
protected bool $defaultWasChanged = false;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']);
return $data;
}
protected function afterSave(): void
{
$tenant = Tenant::current();
$record = $this->getRecord();
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
if ($this->shouldMakeDefault && ! $record->is_default) {
$record->makeDefault();
$this->defaultWasChanged = true;
}
$hasDefault = $tenant->providerConnections()
->where('provider', $record->provider)
->where('is_default', true)
->exists();
if (! $hasDefault) {
$record->makeDefault();
$this->defaultWasChanged = true;
}
$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;
if ($changedFields !== []) {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'fields' => $changedFields,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
}
if ($this->defaultWasChanged) {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
}
}
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
return [
Actions\DeleteAction::make()
->visible(false),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Action::make('view_last_check_run')
->label('View last check run')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->where('context->provider_connection_id', (int) $record->getKey())
->exists())
->url(function (ProviderConnection $record): ?string {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->where('context->provider_connection_id', (int) $record->getKey())
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
return OperationRunLinks::view($run, $tenant);
})
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->tooltip('You do not have permission to view provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
->color('success')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A connection check is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Connection check queued')
->body('Health check was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => $tenant instanceof Tenant)
->form([
TextInput::make('client_id')
->label('Client ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$credentials->upsertClientSecretCredential(
connection: $record,
clientId: (string) $data['client_id'],
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,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Credentials updated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->status !== 'disabled'
&& ! $record->is_default
&& ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->where('provider', $record->provider)
->count() > 1)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$record->makeDefault();
$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.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Default connection updated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'inventory.sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('An inventory sync is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'compliance.snapshot',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderComplianceSnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A compliance snapshot is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
]);
$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.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
'to_status' => $status,
'credentials_present' => $hadCredentials,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->warning()
->send();
return;
}
Notification::make()
->title('Provider connection enabled')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
}
$previousStatus = (string) $record->status;
$record->update([
'status' => 'disabled',
]);
$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.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Provider connection disabled')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
}
protected function getFormActions(): array
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return [
$this->getCancelFormAction(),
];
}
$capabilityResolver = app(CapabilityResolver::class);
if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return parent::getFormActions();
}
return [
$this->getCancelFormAction(),
];
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(404);
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
abort(404);
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
abort(403);
}
return parent::handleRecordUpdate($record, $data);
}
}

View File

@ -0,0 +1,27 @@
<?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\ListRecords;
class ListProviderConnections extends ListRecords
{
protected static string $resource = ProviderConnectionResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\CreateAction::make()
->authorize(fn (): bool => true)
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to create provider connections.')
->apply(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,14 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord
{
@ -16,6 +20,27 @@ class CreateRestoreRun extends CreateRecord
protected static string $resource = RestoreRunResource::class;
protected function authorizeAccess(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(404);
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
abort(404);
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
}
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();
@ -119,4 +144,23 @@ protected function handleRecordCreation(array $data): Model
{
return RestoreRunResource::createRestoreRun($data);
}
#[On('entra-group-cache-picked')]
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
{
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
$this->data['check_summary'] = null;
$this->data['check_results'] = [];
$this->data['checks_ran_at'] = null;
$this->data['preview_summary'] = null;
$this->data['preview_diffs'] = [];
$this->data['preview_ran_at'] = null;
$this->form->fill($this->data);
if (method_exists($this, 'unmountAction')) {
$this->unmountAction();
}
}
}

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -13,7 +15,7 @@ class ListRestoreRuns extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,13 +4,28 @@
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\TenantRole;
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();
@ -20,7 +35,7 @@ protected function afterCreate(): void
}
$user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => TenantRole::Owner->value],
$this->record->getKey() => ['role' => 'owner'],
]);
}
}

View File

@ -3,7 +3,11 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
class EditTenant extends EditRecord
@ -14,7 +18,21 @@ protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
UiEnforcement::forAction(
Action::make('archive')
->label('Archive')
->color('danger')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record): void {
$record->delete();
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to archive tenants.')
->preserveVisibility()
->destructive()
->apply(),
];
}
}

View File

@ -13,7 +13,9 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
];
}
}

View File

@ -3,11 +3,14 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
protected function getHeaderWidgets(): array
{
return [
TenantArchivedBanner::class,
];
}
protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
Actions\EditAction::make(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('admin_consent')
->label('Admin consent')
->icon('heroicon-o-clipboard-document')
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
TenantResource::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record) => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger) {
$record->delete();
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'internal_tenant_id' => (int) $record->getKey(),
'tenant_guid' => (string) $record->tenant_id,
],
]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')

View File

@ -0,0 +1,223 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TenantMembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user_domain')
->label(__('Domain'))
->getStateUsing(function (TenantMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('source')
->badge()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
UiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
try {
$manager->addMember(
tenant: $tenant,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
])
->actions([
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->changeRole(
tenant: $tenant,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->removeMember($tenant, $actor, $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkspace extends CreateRecord
{
protected static string $resource = WorkspaceResource::class;
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
WorkspaceMembership::query()->firstOrCreate(
[
'workspace_id' => $this->record->getKey(),
'user_id' => $user->getKey(),
],
[
'role' => 'owner',
],
);
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
}
}

View File

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

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWorkspaces extends ListRecords
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewWorkspace extends ViewRecord
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Filament\Resources\Workspaces\RelationManagers;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use App\Support\Rbac\WorkspaceUiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WorkspaceMembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user_domain')
->label(__('Domain'))
->getStateUsing(function (WorkspaceMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
WorkspaceUiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
try {
$manager->addMember(
workspace: $workspace,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
])
->actions([
WorkspaceUiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->changeRole(
workspace: $workspace,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
WorkspaceUiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Models\Workspace;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class WorkspaceResource extends Resource
{
protected static ?string $model = Workspace::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->sortable(),
])
->actions([
Actions\ViewAction::make(),
Actions\EditAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListWorkspaces::route('/'),
'create' => Pages\CreateWorkspace::route('/create'),
'view' => Pages\ViewWorkspace::route('/{record}'),
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
WorkspaceMembershipsRelationManager::class,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
final class VerificationReportViewer
{
/**
* @return array<string, mixed>|null
*/
public static function report(OperationRun $run): ?array
{
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
if (! is_array($report)) {
return null;
}
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
return null;
}
return $report;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];
if (array_key_exists('verification_report', $context)) {
return true;
}
return in_array((string) $run->type, ['provider.connection.check'], true);
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Auth;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin
{
public function authenticate(): ?LoginResponse
{
$data = $this->form->getState();
$email = (string) ($data['email'] ?? '');
try {
$response = parent::authenticate();
} catch (ValidationException $exception) {
$this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials');
throw $exception;
}
if (! $response) {
return null;
}
/** @var PlatformUser|null $user */
$user = auth('platform')->user();
if (! ($user instanceof PlatformUser)) {
return $response;
}
if (! $user->is_active) {
auth('platform')->logout();
$this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive');
throw ValidationException::withMessages([
'data.email' => __('filament-panels::auth/pages/login.messages.failed'),
]);
}
$user->forceFill(['last_login_at' => now()])->saveQuietly();
$this->audit(status: 'success', email: $email, actor: $user);
return $response;
}
private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void
{
$tenant = Tenant::query()->where('external_id', 'platform')->first();
if (! $tenant) {
return;
}
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'platform.auth.login',
context: [
'attempted_email' => $email,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'reason' => $reason,
],
actorId: $actor?->getKey(),
actorEmail: $actor?->email ?? ($email ?: null),
actorName: $actor?->name,
status: $status,
resourceType: 'platform_user',
resourceId: $actor ? (string) $actor->getKey() : null,
);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages;
use App\Models\PlatformUser;
use App\Services\Auth\BreakGlassSession;
use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$breakGlass = app(BreakGlassSession::class);
$user = auth('platform')->user();
$canUseBreakGlass = $breakGlass->isEnabled()
&& $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
return [
Action::make('enter_break_glass')
->label('Enter break-glass mode')
->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation()
->modalHeading('Enter break-glass mode')
->modalDescription('Recovery mode is time-limited and fully audited. Use for recovery only.')
->form([
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, BreakGlassSession $breakGlass): void {
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
abort(403);
}
if (! $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
abort(403);
}
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make()
->title('Recovery mode enabled')
->success()
->send();
}),
Action::make('exit_break_glass')
->label('Exit break-glass')
->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation()
->modalHeading('Exit break-glass mode')
->modalDescription('This will immediately end recovery mode.')
->action(function (BreakGlassSession $breakGlass): void {
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
abort(403);
}
$breakGlass->exit($user);
Notification::make()
->title('Recovery mode ended')
->success()
->send();
}),
];
}
}

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages;
use App\Models\PlatformUser;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Auth\WorkspaceRole;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class RepairWorkspaceOwners extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Repair workspace owners';
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
protected string $view = 'filament.system.pages.repair-workspace-owners';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$breakGlass = app(BreakGlassSession::class);
return [
Action::make('assign_owner')
->label('Assign owner (break-glass)')
->color('danger')
->requiresConfirmation()
->modalHeading('Assign workspace owner')
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
->form([
Select::make('workspace_id')
->label('Workspace')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Select::make('target_user_id')
->label('User')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return User::query()
->where('email', 'like', "%{$search}%")
->orderBy('email')
->limit(25)
->pluck('email', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return User::query()->whereKey((int) $value)->value('email');
}),
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
$platformUser = auth('platform')->user();
if (! $platformUser instanceof PlatformUser) {
abort(403);
}
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
abort(403);
}
if (! $breakGlass->isActive()) {
abort(403);
}
$workspaceId = (int) ($data['workspace_id'] ?? 0);
$targetUserId = (int) ($data['target_user_id'] ?? 0);
$reason = (string) ($data['reason'] ?? '');
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
$membership = WorkspaceMembership::query()->firstOrNew([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $targetUser->getKey(),
]);
$fromRole = $membership->exists ? (string) $membership->role : null;
$membership->forceFill([
'role' => WorkspaceRole::Owner->value,
])->save();
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $platformUser->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'attempted_role' => WorkspaceRole::Owner->value,
'from_role' => $fromRole,
'reason' => trim($reason),
'source' => 'break_glass',
],
],
actor: null,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
actorId: (int) $platformUser->getKey(),
actorEmail: $platformUser->email,
actorName: $platformUser->name,
);
Notification::make()
->title('Owner assigned')
->success()
->send();
})
->disabled(fn (): bool => ! $breakGlass->isActive()),
];
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\OperationRunResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class DashboardKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected function getPollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
}
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
Stat::make('Open drift findings', 0),
Stat::make('High severity drift', 0),
Stat::make('Active operations', 0),
Stat::make('Inventory active', 0),
];
}
$tenantId = (int) $tenant->getKey();
$openDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->count();
$highSeverityDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->where('severity', Finding::SEVERITY_HIGH)
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$inventoryActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory.sync')
->active()
->count();
return [
Stat::make('Open drift findings', $openDriftFindings)
->url(FindingResource::getUrl('index', tenant: $tenant)),
Stat::make('High severity drift', $highSeverityDriftFindings)
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
->url(FindingResource::getUrl('index', tenant: $tenant)),
Stat::make('Active operations', $activeRuns)
->color($activeRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
Stat::make('Inventory active', $inventoryActiveRuns)
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
];
}
}

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\DriftLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class NeedsAttention extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.needs-attention';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'items' => [],
'healthyChecks' => [],
];
}
$tenantId = (int) $tenant->getKey();
$items = [];
$highSeverityCount = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->where('severity', Finding::SEVERITY_HIGH)
->count();
if ($highSeverityCount > 0) {
$items[] = [
'title' => 'High severity drift findings',
'body' => "{$highSeverityCount} finding(s) need review.",
'url' => FindingResource::getUrl('index', tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'danger',
];
}
$latestDriftSuccess = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'drift.generate')
->where('status', 'completed')
->where('outcome', 'succeeded')
->whereNotNull('completed_at')
->latest('completed_at')
->first();
if (! $latestDriftSuccess) {
$items[] = [
'title' => 'No drift scan yet',
'body' => 'Generate drift after you have at least two successful inventory runs.',
'url' => DriftLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
} else {
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
if ($isStale) {
$items[] = [
'title' => 'Drift stale',
'body' => 'Last drift scan is older than 7 days.',
'url' => DriftLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
}
}
$latestDriftFailure = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'drift.generate')
->where('status', 'completed')
->where('outcome', 'failed')
->latest('id')
->first();
if ($latestDriftFailure instanceof OperationRun) {
$items[] = [
'title' => 'Drift generation failed',
'body' => 'Investigate the latest failed run.',
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
'badge' => 'Operations',
'badgeColor' => 'danger',
];
}
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
if ($activeRuns > 0) {
$items[] = [
'title' => 'Operations in progress',
'body' => "{$activeRuns} run(s) are active.",
'url' => OperationRunLinks::index($tenant),
'badge' => 'Operations',
'badgeColor' => 'warning',
];
}
$items = array_slice($items, 0, 5);
$healthyChecks = [];
if ($items === []) {
$healthyChecks = [
[
'title' => 'Drift findings look healthy',
'body' => 'No high severity drift findings are open.',
'url' => FindingResource::getUrl('index', tenant: $tenant),
'linkLabel' => 'View findings',
],
[
'title' => 'Drift scans are up to date',
'body' => $latestDriftSuccess?->completed_at
? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.'
: 'Drift scan history is available in Drift.',
'url' => DriftLanding::getUrl(tenant: $tenant),
'linkLabel' => 'Open Drift',
],
[
'title' => 'No active operations',
'body' => 'Nothing is currently running for this tenant.',
'url' => OperationRunLinks::index($tenant),
'linkLabel' => 'View operations',
],
];
}
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'items' => $items,
'healthyChecks' => $healthyChecks,
];
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
class RecentDriftFindings extends TableWidget
{
protected static bool $isLazy = false;
public function table(Table $table): Table
{
$tenant = Filament::getTenant();
return $table
->heading('Recent Drift Findings')
->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
->paginated([10])
->columns([
TextColumn::make('short_id')
->label('ID')
->state(fn (Finding $record): string => '#'.$record->getKey())
->copyable()
->copyableState(fn (Finding $record): string => (string) $record->getKey()),
TextColumn::make('subject_display_name')
->label('Subject')
->placeholder('—')
->limit(40)
->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null),
TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextColumn::make('created_at')
->label('Created')
->since(),
])
->recordUrl(fn (Finding $record): ?string => $tenant instanceof Tenant
? FindingResource::getUrl('view', ['record' => $record], tenant: $tenant)
: null)
->emptyStateHeading('No drift findings')
->emptyStateDescription('You\'re looking good — no drift findings to review yet.');
}
/**
* @return Builder<Finding>
*/
private function getQuery(): Builder
{
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
return Finding::query()
->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->latest('created_at');
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
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\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
class RecentOperations extends TableWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
$tenant = Filament::getTenant();
return $table
->heading('Recent Operations')
->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
->paginated([10])
->columns([
TextColumn::make('short_id')
->label('Run')
->state(fn (OperationRun $record): string => '#'.$record->getKey())
->copyable()
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40)
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('created_at')
->label('Started')
->since(),
])
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
? OperationRunLinks::view($record, $tenant)
: null)
->emptyStateHeading('No operations yet')
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
}
/**
* @return Builder<OperationRun>
*/
private function getQuery(): Builder
{
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
return OperationRun::query()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('created_at');
}
}

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Inventory;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\InventorySyncStatusBadge;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class InventoryKpiHeader extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* Inventory KPI aggregation source-of-truth:
* - `inventory_items.policy_type`
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
* - dependency capability via `CoverageCapabilitiesResolver`
*
* @return array<Stat>
*/
protected function getStats(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
Stat::make('Last inventory sync', '—'),
Stat::make('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
];
}
$tenantId = (int) $tenant->getKey();
/** @var array<string, int> $countsByPolicyType */
$countsByPolicyType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(fn ($value): int => (int) $value)
->all();
$totalItems = array_sum($countsByPolicyType);
$restorableItems = 0;
$partialItems = 0;
$riskItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
$restorableItems += $count;
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
$partialItems += $count;
}
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
$riskItems += $count;
}
}
$coveragePercent = $totalItems > 0
? (int) round(($restorableItems / $totalItems) * 100)
: 0;
$lastRun = InventorySyncRun::query()
->where('tenant_id', $tenantId)
->latest('id')
->first();
$lastInventorySyncTimeLabel = '—';
$lastInventorySyncStatusLabel = '—';
$lastInventorySyncStatusColor = 'gray';
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
$lastInventorySyncViewUrl = null;
if ($lastRun instanceof InventorySyncRun) {
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
if ($timestamp) {
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
}
$status = (string) ($lastRun->status ?? '');
$badge = InventorySyncStatusBadge::for($status);
$lastInventorySyncStatusLabel = $badge['label'];
$lastInventorySyncStatusColor = $badge['color'];
$lastInventorySyncStatusIcon = $badge['icon'];
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
}
$badgeColor = $lastInventorySyncStatusColor;
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
View run
</x-filament::link>
@endif
</div>
BLADE, [
'badgeColor' => $badgeColor,
'statusLabel' => $lastInventorySyncStatusLabel,
'viewUrl' => $lastInventorySyncViewUrl,
]);
$activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory.sync')
->active()
->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [
Stat::make('Total items', $totalItems),
Stat::make('Coverage', $coveragePercent.'%')
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
->description(new HtmlString($lastInventorySyncDescription)),
Stat::make('Active ops', $activeOps),
Stat::make('Inventory ops', $inventoryOps)
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
];
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Operations;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\ActiveRuns;
use Carbon\CarbonInterval;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Collection;
class OperationsKpiHeader extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected function getPollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
}
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total Runs (30 days)', 0),
Stat::make('Active Runs', 0),
Stat::make('Failed/Partial (7 days)', 0),
Stat::make('Avg Duration (7 days)', '—'),
];
}
$tenantId = (int) $tenant->getKey();
$totalRuns30Days = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('created_at', '>=', now()->subDays(30))
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->count();
$failedOrPartial7Days = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->where('completed_at', '>=', now()->subDays(7))
->count();
/** @var Collection<int, OperationRun> $recentCompletedRuns */
$recentCompletedRuns = OperationRun::query()
->where('tenant_id', $tenantId)
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('started_at')
->whereNotNull('completed_at')
->where('completed_at', '>=', now()->subDays(7))
->latest('id')
->limit(200)
->get(['started_at', 'completed_at']);
$durations = $recentCompletedRuns
->map(function (OperationRun $run): ?int {
if (! $run->started_at || ! $run->completed_at) {
return null;
}
$seconds = $run->completed_at->diffInSeconds($run->started_at);
if (is_int($seconds)) {
return $seconds;
}
return (int) round((float) $seconds);
})
->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0)
->values();
$avgDuration7Days = '—';
if ($durations->isNotEmpty()) {
$avgDurationSeconds = (int) round($durations->avg() ?? 0);
$avgDuration7Days = self::formatDurationSeconds($avgDurationSeconds);
}
return [
Stat::make('Total Runs (30 days)', $totalRuns30Days),
Stat::make('Active Runs', $activeRuns),
Stat::make('Failed/Partial (7 days)', $failedOrPartial7Days),
Stat::make('Avg Duration (7 days)', $avgDuration7Days),
];
}
private static function formatDurationSeconds(int $seconds): string
{
if ($seconds <= 0) {
return '—';
}
if ($seconds < 60) {
return $seconds.'s';
}
$interval = CarbonInterval::seconds($seconds)->cascade();
if ($seconds < 3600) {
return sprintf('%dm %ds', $interval->minutes, $interval->seconds);
}
return sprintf('%dh %dm', $interval->hours, $interval->minutes);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class TenantArchivedBanner extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.tenant-archived-banner';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
return [
'tenant' => $tenant instanceof Tenant ? $tenant : null,
];
}
}

View File

@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Models\User;
use App\Services\Auth\PostLoginRedirectResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EntraController
{
public function redirect(Request $request): RedirectResponse
{
$clientId = (string) config('services.microsoft.client_id');
$clientSecret = (string) config('services.microsoft.client_secret');
$redirectUri = (string) config('services.microsoft.redirect');
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$state = (string) Str::uuid();
$request->session()->put('entra_state', $state);
$scopes = implode(' ', ['openid', 'profile', 'email']);
$url = sprintf(
'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s',
$authorityTenant,
http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => $scopes,
'state' => $state,
])
);
return redirect()->away($url);
}
public function callback(Request $request): RedirectResponse
{
$expectedState = $request->session()->pull('entra_state');
if (! is_string($expectedState) || $expectedState === '') {
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
}
if ($expectedState !== $request->string('state')->toString()) {
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
}
if ($request->string('error')->toString() !== '') {
return $this->failRedirect($request, reasonCode: 'oidc_user_denied');
}
$code = $request->string('code')->toString();
if ($code === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$clientId = (string) config('services.microsoft.client_id');
$clientSecret = (string) config('services.microsoft.client_secret');
$redirectUri = (string) config('services.microsoft.redirect');
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$response = Http::asForm()->post(
sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $authorityTenant),
[
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUri,
]
);
if ($response->failed()) {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$payload = $response->json() ?: [];
$idToken = $payload['id_token'] ?? null;
if (! is_string($idToken) || $idToken === '') {
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
}
$claims = $this->decodeJwtClaims($idToken);
$entraTenantId = is_string($claims['tid'] ?? null) ? (string) $claims['tid'] : '';
$entraObjectId = is_string($claims['oid'] ?? null) ? (string) $claims['oid'] : '';
if ($entraTenantId === '' || $entraObjectId === '') {
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
}
$email = $this->resolveEmailFromClaims($claims, $entraTenantId, $entraObjectId);
$name = $this->resolveNameFromClaims($claims, $email);
try {
$existingUser = User::withTrashed()
->where('entra_tenant_id', $entraTenantId)
->where('entra_object_id', $entraObjectId)
->first();
if ($existingUser?->trashed()) {
return $this->failRedirect(
$request,
reasonCode: 'user_disabled',
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
userId: (int) $existingUser->getKey(),
);
}
$isNewUser = $existingUser === null;
$user = $existingUser ?? new User;
$user->fill([
'entra_tenant_id' => $entraTenantId,
'entra_object_id' => $entraObjectId,
'email' => $email,
'name' => $name,
]);
if ($isNewUser) {
$user->password = Str::password(64);
}
$user->save();
} catch (\Throwable $exception) {
return $this->failRedirect(
$request,
reasonCode: 'oidc_user_upsert_failed',
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
);
}
Auth::login($user);
$request->session()->regenerate();
Log::info('auth.entra.login', $this->logContext($request, success: true, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $user->getKey()));
$redirectTo = app(PostLoginRedirectResolver::class)->resolve($user);
return redirect()->to($redirectTo);
}
/**
* @return array<string, mixed>
*/
private function decodeJwtClaims(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) < 2) {
return [];
}
$payload = $this->base64UrlDecode($parts[1]);
if ($payload === null) {
return [];
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
private function base64UrlDecode(string $value): ?string
{
$value = str_replace(['-', '_'], ['+', '/'], $value);
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($value, true);
return $decoded === false ? null : $decoded;
}
/**
* @param array<string, mixed> $claims
*/
private function resolveEmailFromClaims(array $claims, string $entraTenantId, string $entraObjectId): string
{
$candidate = null;
foreach (['preferred_username', 'email', 'upn'] as $key) {
$value = $claims[$key] ?? null;
if (is_string($value) && $value !== '') {
$candidate = $value;
break;
}
}
if (! is_string($candidate) || $candidate === '') {
$candidate = sprintf('%s@%s.entra.invalid', $entraObjectId, $entraTenantId);
}
$candidate = strtolower(trim($candidate));
return Str::limit($candidate, 255, '');
}
/**
* @param array<string, mixed> $claims
*/
private function resolveNameFromClaims(array $claims, string $email): string
{
$candidate = $claims['name'] ?? null;
if (is_string($candidate) && $candidate !== '') {
return Str::limit(trim($candidate), 255, '');
}
$given = $claims['given_name'] ?? null;
$family = $claims['family_name'] ?? null;
if (is_string($given) && is_string($family)) {
$full = trim($given.' '.$family);
if ($full !== '') {
return Str::limit($full, 255, '');
}
}
return Str::limit($email, 255, '');
}
private function failRedirect(
Request $request,
string $reasonCode,
?string $entraTenantId = null,
?string $entraObjectId = null,
?int $userId = null,
): RedirectResponse {
Log::warning('auth.entra.login', $this->logContext(
$request,
success: false,
reasonCode: $reasonCode,
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
userId: $userId,
));
return redirect()
->to('/admin/login')
->with('error', 'Authentication failed. Please try again.');
}
/**
* @return array{success:bool,reason_code?:string,user_id?:int,entra_tenant_id?:string,entra_object_id_hash?:string,correlation_id:string,timestamp:string}
*/
private function logContext(
Request $request,
bool $success,
?string $reasonCode = null,
?string $entraTenantId = null,
?string $entraObjectId = null,
?int $userId = null,
): array {
$correlationId = $request->header('X-Request-Id')
?: ($request->hasSession() ? $request->session()->getId() : null)
?: (string) Str::uuid();
$context = [
'success' => $success,
'correlation_id' => (string) $correlationId,
'timestamp' => now()->toISOString(),
];
if ($reasonCode !== null) {
$context['reason_code'] = $reasonCode;
}
if ($userId !== null) {
$context['user_id'] = $userId;
}
if ($entraTenantId !== null) {
$context['entra_tenant_id'] = $entraTenantId;
}
if ($entraObjectId !== null) {
$context['entra_object_id_hash'] = hash('sha256', $entraObjectId);
}
return $context;
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
final class SelectTenantController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId === null) {
return redirect()->to('/admin/choose-workspace');
}
$validated = $request->validate([
'tenant_id' => ['required', 'integer'],
]);
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $workspaceId)
->whereKey($validated['tenant_id'])
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
return;
}
if (! Schema::hasTable('user_tenant_preferences')) {
return;
}
UserTenantPreference::query()->updateOrCreate(
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
['last_used_at' => now()]
);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SwitchWorkspaceController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$validated = $request->validate([
'workspace_id' => ['required', 'integer'],
]);
$workspace = Workspace::query()->whereKey($validated['workspace_id'])->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}
return redirect()->to(ChooseTenant::getUrl());
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCorrectGuard
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $expectedGuard): Response
{
$expectedGuard = trim($expectedGuard);
if ($expectedGuard === '') {
return $next($request);
}
$knownGuards = [
'web',
'platform',
];
foreach ($knownGuards as $guard) {
if ($guard === $expectedGuard) {
continue;
}
if (auth($guard)->check()) {
abort(404);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
class EnsurePlatformCapability
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $capability): Response
{
$capability = trim($capability);
if ($capability === '') {
return $next($request);
}
$user = auth('platform')->user();
if ($user === null) {
return $next($request);
}
if (! Gate::forUser($user)->allows($capability)) {
abort(404);
}
return $next($request);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceMember
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
$workspaceParam = $request->route()?->parameter('workspace');
$workspace = $workspaceParam instanceof Workspace
? $workspaceParam
: (is_scalar($workspaceParam)
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
: null);
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
return $next($request);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
if ($workspace !== null) {
return $next($request);
}
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
return new HttpResponse('', 302, ['Location' => $target]);
}
}

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