Compare commits
23 Commits
codex/134-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 |
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
||||
---
|
||||
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||
|
||||
Your task is to produce spec candidates, not implementation code.
|
||||
|
||||
Before writing anything, read and use these repository files as binding context:
|
||||
|
||||
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||
- `specs/110-ops-ux-enforcement/spec.md`
|
||||
- `specs/111-findings-workflow-sla/spec.md`
|
||||
- `specs/134-audit-log-foundation/spec.md`
|
||||
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||
|
||||
The four candidate themes are:
|
||||
|
||||
1. queued execution reauthorization and scope continuity
|
||||
2. tenant-owned query canon and wrong-tenant guards
|
||||
3. findings workflow enforcement and audit backstop
|
||||
4. Livewire context locking and trusted-state reduction
|
||||
|
||||
## Numbering rule
|
||||
|
||||
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||
|
||||
## Output requirements
|
||||
|
||||
Create exactly four spec candidates, one per problem class.
|
||||
|
||||
For each candidate provide:
|
||||
|
||||
1. Candidate label or confirmed spec number
|
||||
2. Working title
|
||||
3. Status: `Proposed`
|
||||
4. Summary
|
||||
5. Why this is needed now
|
||||
6. Boundary to existing specs
|
||||
7. Problem statement
|
||||
8. Goals
|
||||
9. Non-goals
|
||||
10. Scope
|
||||
11. Target model
|
||||
12. Key requirements
|
||||
13. Risks if not implemented
|
||||
14. Dependencies and sequencing notes
|
||||
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||
17. Suggested slug
|
||||
|
||||
At the end provide:
|
||||
|
||||
A. Recommended implementation order
|
||||
B. Which candidates can run in parallel
|
||||
C. Which candidate should start first and why
|
||||
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Write in English.
|
||||
- Use formal enterprise spec language.
|
||||
- Be concrete and opinionated.
|
||||
- Focus on structural integrity, not patch-level fixes.
|
||||
- Treat the audit constitution as binding.
|
||||
- Explicitly say when UI-only authorization is insufficient.
|
||||
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||
- Explicitly say when negative-path regression tests are required.
|
||||
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||
- Do not collapse all four themes into one umbrella spec.
|
||||
|
||||
## Candidate-specific direction
|
||||
|
||||
### Candidate A — queued execution reauthorization and scope continuity
|
||||
|
||||
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||
|
||||
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||
|
||||
- Treat this as canonical data-access hardening.
|
||||
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||
|
||||
### Candidate C — findings workflow enforcement and audit backstop
|
||||
|
||||
- Treat this as a workflow-truth problem.
|
||||
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||
- Make clear how this extends but does not duplicate Spec 111.
|
||||
|
||||
### Candidate D — Livewire context locking and trusted-state reduction
|
||||
|
||||
- Treat this as a UI/server trust-boundary hardening problem.
|
||||
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||
- Make clear how this complements but does not duplicate Spec 138.
|
||||
40
.github/agents/copilot-instructions.md
vendored
40
.github/agents/copilot-instructions.md
vendored
@ -62,6 +62,40 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
|
||||
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
||||
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
||||
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -81,8 +115,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 131-cross-resource-navigation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
---
|
||||
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||
agent: speckit.specify
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||
|
||||
Your task is to produce spec candidates, not implementation code.
|
||||
|
||||
Before writing anything, read and use these repository files as binding context:
|
||||
|
||||
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||
- `specs/110-ops-ux-enforcement/spec.md`
|
||||
- `specs/111-findings-workflow-sla/spec.md`
|
||||
- `specs/134-audit-log-foundation/spec.md`
|
||||
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||
|
||||
The four candidate themes are:
|
||||
|
||||
1. queued execution reauthorization and scope continuity
|
||||
2. tenant-owned query canon and wrong-tenant guards
|
||||
3. findings workflow enforcement and audit backstop
|
||||
4. Livewire context locking and trusted-state reduction
|
||||
|
||||
## Numbering rule
|
||||
|
||||
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||
|
||||
## Output requirements
|
||||
|
||||
Create exactly four spec candidates, one per problem class.
|
||||
|
||||
For each candidate provide:
|
||||
|
||||
1. Candidate label or confirmed spec number
|
||||
2. Working title
|
||||
3. Status: `Proposed`
|
||||
4. Summary
|
||||
5. Why this is needed now
|
||||
6. Boundary to existing specs
|
||||
7. Problem statement
|
||||
8. Goals
|
||||
9. Non-goals
|
||||
10. Scope
|
||||
11. Target model
|
||||
12. Key requirements
|
||||
13. Risks if not implemented
|
||||
14. Dependencies and sequencing notes
|
||||
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||
17. Suggested slug
|
||||
|
||||
At the end provide:
|
||||
|
||||
A. Recommended implementation order
|
||||
B. Which candidates can run in parallel
|
||||
C. Which candidate should start first and why
|
||||
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Write in English.
|
||||
- Use formal enterprise spec language.
|
||||
- Be concrete and opinionated.
|
||||
- Focus on structural integrity, not patch-level fixes.
|
||||
- Treat the audit constitution as binding.
|
||||
- Explicitly say when UI-only authorization is insufficient.
|
||||
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||
- Explicitly say when negative-path regression tests are required.
|
||||
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||
- Do not collapse all four themes into one umbrella spec.
|
||||
|
||||
## Candidate-specific direction
|
||||
|
||||
### Candidate A — queued execution reauthorization and scope continuity
|
||||
|
||||
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||
|
||||
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||
|
||||
- Treat this as canonical data-access hardening.
|
||||
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||
|
||||
### Candidate C — findings workflow enforcement and audit backstop
|
||||
|
||||
- Treat this as a workflow-truth problem.
|
||||
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||
- Make clear how this extends but does not duplicate Spec 111.
|
||||
|
||||
### Candidate D — Livewire context locking and trusted-state reduction
|
||||
|
||||
- Treat this as a UI/server trust-boundary hardening problem.
|
||||
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||
- Make clear how this complements but does not duplicate Spec 138.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,5 +32,6 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/tests/Browser/Screenshots
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.10.0 → 1.11.0
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- None
|
||||
- Scope & Ownership Clarification (SCOPE-001)
|
||||
- Added sections:
|
||||
- Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- None
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .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)
|
||||
- None
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -68,6 +65,7 @@ ### Tenant Isolation is Non-negotiable
|
||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||
|
||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
|
||||
|
||||
@ -681,7 +681,7 @@ ## 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.1
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
||||
@ -521,7 +521,7 @@ ## 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.1
|
||||
- php - 8.4.15
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
||||
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||
use App\Services\Providers\ProviderConnectionClassifier;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClassifyProviderConnections extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:provider-connections:classify
|
||||
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
|
||||
{--connection= : Restrict to a single provider connection id}
|
||||
{--provider=microsoft : Restrict to one provider}
|
||||
{--chunk=100 : Chunk size for large write runs}
|
||||
{--write : Persist the classification results}';
|
||||
|
||||
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
|
||||
|
||||
public function handle(
|
||||
ProviderConnectionClassifier $classifier,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): int {
|
||||
$query = $this->query();
|
||||
$write = (bool) $this->option('write');
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
|
||||
$candidateCount = (clone $query)->count();
|
||||
|
||||
if ($candidateCount === 0) {
|
||||
$this->info('No provider connections matched the classification scope.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tenantCounts = (clone $query)
|
||||
->selectRaw('tenant_id, count(*) as aggregate')
|
||||
->groupBy('tenant_id')
|
||||
->pluck('aggregate', 'tenant_id')
|
||||
->map(static fn (mixed $count): int => (int) $count)
|
||||
->all();
|
||||
|
||||
$startedTenants = [];
|
||||
$classifiedCount = 0;
|
||||
$appliedCount = 0;
|
||||
$reviewRequiredCount = 0;
|
||||
|
||||
$query
|
||||
->with(['tenant', 'credential'])
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($connections) use (
|
||||
$classifier,
|
||||
$stateProjector,
|
||||
$write,
|
||||
$tenantCounts,
|
||||
&$startedTenants,
|
||||
&$classifiedCount,
|
||||
&$appliedCount,
|
||||
&$reviewRequiredCount,
|
||||
): void {
|
||||
foreach ($connections as $connection) {
|
||||
$classifiedCount++;
|
||||
|
||||
$result = $classifier->classify(
|
||||
$connection,
|
||||
source: 'tenantpilot:provider-connections:classify',
|
||||
);
|
||||
|
||||
if ($result->reviewRequired) {
|
||||
$reviewRequiredCount++;
|
||||
}
|
||||
|
||||
if (! $write) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenant = $connection->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantKey = (int) $tenant->getKey();
|
||||
|
||||
if (! array_key_exists($tenantKey, $startedTenants)) {
|
||||
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
|
||||
$startedTenants[$tenantKey] = true;
|
||||
}
|
||||
|
||||
$connection = $this->applyClassification($connection, $result, $stateProjector);
|
||||
$this->auditApplied($tenant, $connection, $result);
|
||||
$appliedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($write) {
|
||||
$this->info(sprintf('Applied classifications: %d', $appliedCount));
|
||||
} else {
|
||||
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
|
||||
}
|
||||
|
||||
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
|
||||
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function query(): Builder
|
||||
{
|
||||
$query = ProviderConnection::query()
|
||||
->where('provider', (string) $this->option('provider'));
|
||||
|
||||
$tenantOption = $this->option('tenant');
|
||||
|
||||
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant(trim($tenantOption))
|
||||
->firstOrFail();
|
||||
|
||||
$query->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
$connectionOption = $this->option('connection');
|
||||
|
||||
if (is_numeric($connectionOption)) {
|
||||
$query->whereKey((int) $connectionOption);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyClassification(
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): ProviderConnection {
|
||||
DB::transaction(function () use ($connection, $result, $stateProjector): void {
|
||||
$connection->forceFill(
|
||||
$connection->classificationProjection($result, $stateProjector)
|
||||
)->save();
|
||||
|
||||
$credential = $connection->credential;
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (
|
||||
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
|
||||
&& $credential->source === null
|
||||
) {
|
||||
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
|
||||
}
|
||||
|
||||
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
|
||||
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$credential->forceFill($updates)->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $connection->fresh(['tenant', 'credential']);
|
||||
}
|
||||
|
||||
private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||
{
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.migration_classification_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'provider' => 'microsoft',
|
||||
'candidate_count' => $candidateCount,
|
||||
'write' => true,
|
||||
],
|
||||
],
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
private function auditApplied(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
): void {
|
||||
$effectiveApp = $connection->effectiveAppMetadata();
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.migration_classification_applied',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'tenantpilot:provider-connections:classify',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'migration_review_required' => $connection->migration_review_required,
|
||||
'legacy_identity_result' => $result->suggestedConnectionType->value,
|
||||
'effective_app_id' => $effectiveApp['app_id'],
|
||||
'effective_app_source' => $effectiveApp['source'],
|
||||
'signals' => $result->signals,
|
||||
],
|
||||
],
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
@ -33,7 +34,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
||||
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
@ -88,10 +89,6 @@ public function handle(): int
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
@ -150,7 +147,7 @@ private function countsForTenant(Tenant $tenant): array
|
||||
return [
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs_retained' => 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(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
@ -164,6 +161,8 @@ private function countsForTenant(Tenant $tenant): array
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
{
|
||||
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
@ -179,15 +178,16 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
'summary_counts' => [
|
||||
'total' => array_sum($counts),
|
||||
'processed' => array_sum($counts),
|
||||
'succeeded' => array_sum($counts),
|
||||
'total' => array_sum($deletedRows),
|
||||
'processed' => array_sum($deletedRows),
|
||||
'succeeded' => array_sum($deletedRows),
|
||||
'failed' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'source' => 'tenantpilot:purge-nonpersistent',
|
||||
'deleted_rows' => $counts,
|
||||
'deleted_rows' => $deletedRows,
|
||||
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
|
||||
],
|
||||
'started_at' => now(),
|
||||
'completed_at' => now(),
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Onboarding;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OnboardingDraftConflictException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $draftId,
|
||||
public readonly int $expectedVersion,
|
||||
public readonly int $actualVersion,
|
||||
string $message = 'This onboarding draft changed in another tab or session.',
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Onboarding;
|
||||
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
use RuntimeException;
|
||||
|
||||
class OnboardingDraftImmutableException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $draftId,
|
||||
public readonly OnboardingLifecycleState $lifecycleState,
|
||||
string $message = 'This onboarding draft is no longer editable.',
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\Evidence\EvidenceResolutionResult;
|
||||
use RuntimeException;
|
||||
|
||||
class ReviewPackEvidenceResolutionException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly EvidenceResolutionResult $result,
|
||||
?string $message = null,
|
||||
) {
|
||||
parent::__construct($message ?? self::defaultMessage($result));
|
||||
}
|
||||
|
||||
private static function defaultMessage(EvidenceResolutionResult $result): string
|
||||
{
|
||||
return match ($result->outcome) {
|
||||
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
||||
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
||||
default => 'Evidence snapshot resolution failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
@ -18,4 +19,13 @@ class InventoryCluster extends Cluster
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Items';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait InteractsWithTenantOwnedRecords
|
||||
{
|
||||
protected static function tenantOwnedRelationshipName(): string
|
||||
{
|
||||
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
|
||||
? static::$tenantOwnershipRelationshipName
|
||||
: null;
|
||||
|
||||
return is_string($relationshipName) && $relationshipName !== ''
|
||||
? $relationshipName
|
||||
: 'tenant';
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
||||
{
|
||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
if (method_exists(static::class, 'panelTenantContext')) {
|
||||
return static::panelTenantContext();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getTenantOwnedEloquentQuery(): Builder
|
||||
{
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
||||
}
|
||||
|
||||
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
|
||||
{
|
||||
return app(TenantOwnedQueryScope::class)->apply(
|
||||
$query,
|
||||
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
|
||||
static::tenantOwnedRelationshipName(),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
$tenant,
|
||||
);
|
||||
|
||||
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
|
||||
}
|
||||
|
||||
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
|
||||
{
|
||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||
$query ?? parent::getEloquentQuery(),
|
||||
$tenant,
|
||||
);
|
||||
|
||||
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
|
||||
}
|
||||
}
|
||||
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use RuntimeException;
|
||||
|
||||
trait ResolvesPanelTenantContext
|
||||
{
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
public static function panelTenantContext(): ?Tenant
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function trustedPanelTenantContext(): ?Tenant
|
||||
{
|
||||
return static::panelTenantContext();
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('No tenant context selected.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -19,6 +22,10 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
$query = static::getModel()::query();
|
||||
|
||||
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if (! static::isScopedToTenant()) {
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
@ -27,7 +34,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveGlobalSearchTenant();
|
||||
|
||||
if (! $tenant instanceof Model) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
@ -41,4 +48,17 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
|
||||
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
||||
}
|
||||
|
||||
protected static function resolveGlobalSearchTenant(): ?Model
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant instanceof Model ? $tenant : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -28,6 +29,8 @@
|
||||
|
||||
class BaselineCompareLanding extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
@ -94,7 +97,7 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
@ -112,7 +115,7 @@ public function mount(): void
|
||||
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
||||
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
||||
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
@ -292,10 +295,10 @@ private function compareNowAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()->title('No tenant context')->danger()->send();
|
||||
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -340,7 +343,7 @@ private function compareNowAction(): Action
|
||||
|
||||
public function getFindingsUrl(): ?string
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -355,7 +358,7 @@ public function getRunUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
|
||||
@ -7,6 +7,10 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
@ -52,10 +56,10 @@ public function getTenants(): Collection
|
||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||
|
||||
if ($tenants instanceof Collection) {
|
||||
return $tenants;
|
||||
return app(TenantOperabilityService::class)->filterSelectable($tenants);
|
||||
}
|
||||
|
||||
return collect($tenants);
|
||||
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||
}
|
||||
|
||||
public function selectTenant(int $tenantId): void
|
||||
@ -66,10 +70,35 @@ public function selectTenant(int $tenantId): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId(request());
|
||||
$tenant = null;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -79,13 +108,32 @@ public function selectTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
);
|
||||
|
||||
if (! $outcome->allowed) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||
{
|
||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -20,6 +21,7 @@
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Enums\FontFamily;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
@ -36,6 +38,7 @@
|
||||
class InventoryCoverage extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||
|
||||
@ -49,9 +52,18 @@ class InventoryCoverage extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
|
||||
@ -4,14 +4,46 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class AuditLog extends Page
|
||||
class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -28,14 +60,339 @@ class AuditLog extends Page
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($this->selectedAuditLogId !== null) {
|
||||
$this->selectedAuditLog();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
|
||||
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||
$actions[] = Action::make('clear_selected_audit_event')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->clearSelectedAuditLog();
|
||||
});
|
||||
|
||||
$relatedLink = $this->selectedAuditLink();
|
||||
|
||||
if (is_array($relatedLink)) {
|
||||
$actions[] = Action::make('open_selected_audit_target')
|
||||
->label($relatedLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($relatedLink['url']);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->auditBaseQuery())
|
||||
->defaultSort('recorded_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
TextColumn::make('outcome')
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value)
|
||||
->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state))
|
||||
->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state))
|
||||
->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state))
|
||||
->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)),
|
||||
TextColumn::make('summary')
|
||||
->label('Event')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText())
|
||||
->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action))
|
||||
->searchable()
|
||||
->wrap(),
|
||||
TextColumn::make('actor_label')
|
||||
->label('Actor')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel())
|
||||
->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value))
|
||||
->searchable(),
|
||||
TextColumn::make('target_label')
|
||||
->label('Target')
|
||||
->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('recorded_at')
|
||||
->label('Recorded')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
SelectFilter::make('action')
|
||||
->label('Event type')
|
||||
->options(fn (): array => $this->actionFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('outcome')
|
||||
->label('Outcome')
|
||||
->options(FilterOptionCatalog::auditOutcomes()),
|
||||
SelectFilter::make('actor_label')
|
||||
->label('Actor')
|
||||
->options(fn (): array => $this->actorFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('resource_type')
|
||||
->label('Target type')
|
||||
->options(fn (): array => $this->targetTypeFilterOptions()),
|
||||
FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('inspect')
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearSelectedAuditLog(): void
|
||||
{
|
||||
$this->selectedAuditLogId = null;
|
||||
}
|
||||
|
||||
public function selectedAuditLog(): ?AuditLogModel
|
||||
{
|
||||
if (! is_numeric($this->selectedAuditLogId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->auditBaseQuery()
|
||||
->whereKey((int) $this->selectedAuditLogId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function selectedAuditLink(): ?array
|
||||
{
|
||||
$record = $this->selectedAuditLog();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $user instanceof User || ! is_numeric($workspaceId)) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
||||
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||
->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
return $this->authorizedTenants = $tenants;
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function auditBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$authorizedTenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
);
|
||||
|
||||
return AuditLogModel::query()
|
||||
->with(['tenant', 'workspace', 'operationRun'])
|
||||
->forWorkspace((int) $workspaceId)
|
||||
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
||||
$query->whereNull('tenant_id');
|
||||
|
||||
if ($authorizedTenantIds !== []) {
|
||||
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
||||
}
|
||||
})
|
||||
->latestFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants())
|
||||
? (string) $activeTenant->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function actionFilterOptions(): array
|
||||
{
|
||||
$values = (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->select('action')
|
||||
->distinct()
|
||||
->orderBy('action')
|
||||
->pluck('action')
|
||||
->all();
|
||||
|
||||
return FilterOptionCatalog::auditActions($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function actorFilterOptions(): array
|
||||
{
|
||||
return (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->whereNotNull('actor_label')
|
||||
->select('actor_label')
|
||||
->distinct()
|
||||
->orderBy('actor_label')
|
||||
->pluck('actor_label', 'actor_label')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function targetTypeFilterOptions(): array
|
||||
{
|
||||
$values = (clone $this->auditBaseQuery())
|
||||
->reorder()
|
||||
->whereNotNull('resource_type')
|
||||
->select('resource_type')
|
||||
->distinct()
|
||||
->orderBy('resource_type')
|
||||
->pluck('resource_type')
|
||||
->all();
|
||||
|
||||
return FilterOptionCatalog::auditTargetTypes($values);
|
||||
}
|
||||
}
|
||||
|
||||
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceOverview extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $title = 'Evidence Overview';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
||||
|
||||
/**
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
public ?int $tenantFilter = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new AuthenticationException;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
|
||||
$accessibleTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->get()
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
->values();
|
||||
|
||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||
|
||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->where('status', 'active')
|
||||
->latest('generated_at');
|
||||
|
||||
if ($this->tenantFilter !== null) {
|
||||
$query->where('tenant_id', $this->tenantFilter);
|
||||
}
|
||||
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||
->url(route('admin.evidence.overview')),
|
||||
];
|
||||
}
|
||||
}
|
||||
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
@ -0,0 +1,503 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingExceptionsQueue extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Finding exceptions';
|
||||
|
||||
protected static ?string $slug = 'finding-exceptions/queue';
|
||||
|
||||
protected static ?string $title = 'Finding Exceptions Queue';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->selectedFindingException();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_finding_exceptions',
|
||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||
);
|
||||
|
||||
$actions[] = Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
$actions[] = Action::make('view_tenant_register')
|
||||
->label('View tenant register')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
||||
->url(function (): ?string {
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
});
|
||||
|
||||
$actions[] = Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
});
|
||||
|
||||
$actions[] = Action::make('open_selected_exception')
|
||||
->label('Open tenant detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
||||
|
||||
$actions[] = Action::make('open_selected_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
||||
|
||||
$actions[] = Action::make('approve_selected_exception')
|
||||
->label('Approve exception')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
DateTimePicker::make('effective_from')
|
||||
->label('Effective from')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
Textarea::make('approval_reason')
|
||||
->label('Approval reason')
|
||||
->rows(3)
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->selectedFindingException();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$wasRenewalRequest = $record->isPendingRenewal();
|
||||
$updated = $service->approve($record, $user, $data);
|
||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
|
||||
$actions[] = Action::make('reject_selected_exception')
|
||||
->label('Reject exception')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('rejection_reason')
|
||||
->label('Rejection reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->selectedFindingException();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$wasRenewalRequest = $record->isPendingRenewal();
|
||||
$updated = $service->reject($record, $user, $data);
|
||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->queueBaseQuery())
|
||||
->defaultSort('requested_at', 'asc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||||
TextColumn::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||
->searchable(),
|
||||
TextColumn::make('requester.name')
|
||||
->label('Requested by')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('owner.name')
|
||||
->label('Owner')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_due_at')
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Expires')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('requested_at')
|
||||
->label('Requested')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||||
SelectFilter::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||
])
|
||||
->actions([
|
||||
Action::make('inspect_exception')
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (FindingException $record): void {
|
||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No exceptions match this queue')
|
||||
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
|
||||
->emptyStateIcon('heroicon-o-shield-check')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->removeTableFilter('tenant_id');
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function selectedFindingException(): ?FindingException
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->queueBaseQuery()
|
||||
->whereKey($this->selectedFindingExceptionId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
{
|
||||
$record = $this->selectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function selectedFindingUrl(): ?string
|
||||
{
|
||||
$record = $this->selectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->get();
|
||||
|
||||
return $this->authorizedTenants = $tenants
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function queueBaseQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantIds = array_values(array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->authorizedTenants(),
|
||||
));
|
||||
|
||||
return FindingException::query()
|
||||
->with([
|
||||
'tenant',
|
||||
'requester',
|
||||
'owner',
|
||||
'approver',
|
||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||
'decisions.actor',
|
||||
'evidenceReferences',
|
||||
])
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return Collection::make($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant');
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function filteredTenant(): ?Tenant
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
if (! is_int($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $tenantId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
|
||||
private function hasActiveQueueFilters(): bool
|
||||
{
|
||||
return $this->currentTenantFilterId() !== null
|
||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -51,6 +52,13 @@ class Operations extends Page implements HasForms, HasTable
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['type', 'initiator_name'],
|
||||
request(),
|
||||
);
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
@ -131,8 +139,11 @@ public function table(Table $table): Table
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
@ -146,8 +157,8 @@ public function table(Table $table): Table
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
@ -19,6 +20,9 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -56,6 +60,8 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
@ -64,14 +70,12 @@ protected function getHeaderActions(): array
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
@ -102,14 +106,7 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
||||
$tenant = null;
|
||||
}
|
||||
|
||||
$related = OperationRunLinks::related($this->run, $tenant);
|
||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
||||
|
||||
$relatedActions = [];
|
||||
|
||||
@ -163,6 +160,91 @@ public function redactionIntegrityNote(): ?string
|
||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function blockedExecutionBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$reasonCode = data_get($context, 'reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||
}
|
||||
|
||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||
$message = $this->run->failure_summary[0]['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Execution blocked',
|
||||
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function canonicalContextBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$runTenant = $this->run->tenant;
|
||||
|
||||
if (! $runTenant instanceof Tenant) {
|
||||
return [
|
||||
'tone' => 'slate',
|
||||
'title' => 'Workspace-level run',
|
||||
'body' => $activeTenant instanceof Tenant
|
||||
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
|
||||
: 'This canonical workspace view is not tied to any tenant.',
|
||||
];
|
||||
}
|
||||
|
||||
$messages = ['Run tenant: '.$runTenant->name.'.'];
|
||||
$tone = 'sky';
|
||||
$title = null;
|
||||
|
||||
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
||||
$title = 'Current tenant context differs from this run';
|
||||
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
||||
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
||||
}
|
||||
|
||||
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
||||
|
||||
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
||||
$title ??= 'Run tenant is not available in the current tenant selector';
|
||||
$tone = 'amber';
|
||||
$messages[] = $selectorAvailabilityMessage;
|
||||
|
||||
if ($referencedTenant->contextNote !== null) {
|
||||
$messages[] = $referencedTenant->contextNote;
|
||||
}
|
||||
} elseif (! $activeTenant instanceof Tenant) {
|
||||
$title ??= 'Canonical workspace view';
|
||||
$messages[] = 'No tenant context is currently selected.';
|
||||
}
|
||||
|
||||
if ($title === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'tone' => $tone,
|
||||
'title' => $title,
|
||||
'body' => implode(' ', $messages),
|
||||
];
|
||||
}
|
||||
|
||||
public function pollInterval(): ?string
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
@ -313,4 +395,30 @@ private function canResumeCapture(): bool
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function relatedLinksTenant(): ?Tenant
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
)->allowed ? $tenant : null;
|
||||
}
|
||||
}
|
||||
|
||||
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class ReviewRegister extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Reviews';
|
||||
|
||||
protected static ?string $title = 'Review Register';
|
||||
|
||||
protected static ?string $slug = 'reviews';
|
||||
|
||||
protected string $view = 'filament.pages.reviews.review-register';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['status', 'published_state', 'completeness_state'],
|
||||
request(),
|
||||
);
|
||||
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->registerQuery())
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('summary.publish_blockers')
|
||||
->label('Publish blockers')
|
||||
->formatStateUsing(static function (mixed $state): string {
|
||||
if (! is_array($state) || $state === []) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return (string) count($state);
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'draft' => 'Draft',
|
||||
'ready' => 'Ready',
|
||||
'published' => 'Published',
|
||||
'archived' => 'Archived',
|
||||
'superseded' => 'Superseded',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
SelectFilter::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
SelectFilter::make('published_state')
|
||||
->label('Published state')
|
||||
->options([
|
||||
'published' => 'Published',
|
||||
'unpublished' => 'Not published',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'published' => $query->whereNotNull('published_at'),
|
||||
'unpublished' => $query->whereNull('published_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
||||
Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||
&& in_array($record->status, ['ready', 'published'], true))
|
||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No review records match this view')
|
||||
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(fn (): mixed => $this->resetTable()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$service = app(TenantReviewRegisterService::class);
|
||||
|
||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->authorizedTenants() === []) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function registerQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return TenantReview::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant');
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveFilters(): bool
|
||||
{
|
||||
$filters = array_filter((array) $this->tableFilters);
|
||||
|
||||
return $filters !== [];
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_numeric($workspaceId)
|
||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
@ -17,6 +17,8 @@
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
@ -29,7 +31,7 @@ class TenantDiagnostics extends Page
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
@ -80,7 +82,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
@ -94,7 +96,7 @@ public function bootstrapOwner(): void
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
{
|
||||
@ -41,7 +43,8 @@ class TenantRequiredPermissions extends Page
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
public ?Tenant $scopedTenant = null;
|
||||
#[Locked]
|
||||
public ?int $scopedTenantId = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
@ -50,7 +53,7 @@ public static function canAccess(): bool
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
{
|
||||
return $this->scopedTenant;
|
||||
return $this->trustedScopedTenant();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
@ -61,7 +64,9 @@ public function mount(): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->scopedTenant = $tenant;
|
||||
$this->scopedTenantId = (int) $tenant->getKey();
|
||||
$this->heading = $tenant->getFilamentName();
|
||||
$this->subheading = 'Required permissions';
|
||||
|
||||
$queryFeatures = request()->query('features', $this->features);
|
||||
|
||||
@ -141,7 +146,7 @@ public function resetFilters(): void
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
@ -170,7 +175,7 @@ private function refreshViewModel(): void
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
@ -181,7 +186,7 @@ public function reRunVerificationUrl(): string
|
||||
|
||||
public function manageProviderConnectionUrl(): ?string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -232,4 +237,47 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
private function trustedScopedTenant(): ?Tenant
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
try {
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$routeTenant = static::resolveScopedTenant();
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
try {
|
||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->scopedTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,14 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@ -54,11 +58,25 @@ public function getTenants(): Collection
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('tenant_id');
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
->get()
|
||||
->filter(function (Tenant $tenant) use ($user): bool {
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
actor: $user,
|
||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
)->allowed;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
@ -75,7 +93,7 @@ public function openTenant(int $tenantId): void
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->withTrashed()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
@ -88,6 +106,6 @@ public function openTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -15,21 +15,7 @@ class ListAlertDeliveries extends ListRecords
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$filtersSessionKey = $this->getTableFiltersSessionKey();
|
||||
$persistedFilters = session()->get($filtersSessionKey, []);
|
||||
|
||||
if (! is_array($persistedFilters)) {
|
||||
$persistedFilters = [];
|
||||
}
|
||||
|
||||
if (! is_string(data_get($persistedFilters, 'tenant_id.value'))) {
|
||||
data_set($persistedFilters, 'tenant_id.value', (string) $activeTenant->getKey());
|
||||
session()->put($filtersSessionKey, $persistedFilters);
|
||||
}
|
||||
}
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
@ -38,6 +40,7 @@
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -62,6 +65,9 @@
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -70,9 +76,18 @@ class BackupScheduleResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -88,7 +103,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -112,7 +127,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -128,7 +143,7 @@ public static function canCreate(): bool
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -144,7 +159,7 @@ public static function canEdit(Model $record): bool
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -160,7 +175,7 @@ public static function canDelete(Model $record): bool
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -421,7 +436,7 @@ public static function table(Table $table): Table
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -492,7 +507,7 @@ public static function table(Table $table): Table
|
||||
->color('warning')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -568,6 +583,8 @@ public static function table(Table $table): Table
|
||||
->color('danger')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('delete', $record);
|
||||
|
||||
if ($record->trashed()) {
|
||||
@ -609,6 +626,8 @@ public static function table(Table $table): Table
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('restore', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
@ -649,6 +668,8 @@ public static function table(Table $table): Table
|
||||
->color('danger')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('forceDelete', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
@ -706,7 +727,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -721,7 +742,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -803,7 +824,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -818,7 +839,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -906,17 +927,32 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return static::getEloquentQuery()->withTrashed();
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
}
|
||||
|
||||
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
|
||||
{
|
||||
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
|
||||
if (! $resolvedRecord instanceof BackupSchedule) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
@ -1027,7 +1063,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
@ -13,15 +12,7 @@ class EditBackupSchedule extends EditRecord
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
$record = BackupScheduleResource::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->find($key);
|
||||
|
||||
if ($record === null) {
|
||||
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
|
||||
@ -3,12 +3,38 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class ListBackupSchedules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) {
|
||||
try {
|
||||
BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
@ -28,4 +54,14 @@ private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: [],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Closure;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
@ -24,6 +25,19 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||
|
||||
protected static ?string $title = 'Executions';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
@ -48,7 +62,7 @@ public function table(Table $table): Table
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
@ -87,6 +101,7 @@ public function table(Table $table): Table
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
@ -97,4 +112,32 @@ public function table(Table $table): Table
|
||||
->emptyStateHeading('No schedule runs yet')
|
||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
{
|
||||
$recordId = $record instanceof OperationRun
|
||||
? (int) $record->getKey()
|
||||
: (is_numeric($record) ? (int) $record : 0);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedRecord = $this->getOwnerRecord()
|
||||
->operationRuns()
|
||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof OperationRun) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
public static function formatOperationType(?string $state): string
|
||||
{
|
||||
return OperationCatalog::label($state);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
@ -55,6 +57,9 @@
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -63,6 +68,15 @@ class BackupSetResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
@ -76,7 +90,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -92,7 +106,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -108,13 +122,12 @@ public static function canCreate(): bool
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
return static::getTenantOwnedEloquentQuery();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -182,7 +195,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
@ -215,7 +228,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$record->delete();
|
||||
|
||||
@ -247,7 +260,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
@ -317,7 +330,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -387,7 +400,7 @@ public static function table(Table $table): Table
|
||||
->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();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -472,7 +485,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -622,7 +635,7 @@ private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextE
|
||||
public static function createBackupSet(array $data): BackupSet
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
/** @var BackupService $service */
|
||||
$service = app(BackupService::class);
|
||||
|
||||
@ -3,12 +3,24 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSets extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -16,11 +17,19 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return BackupSetResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [
|
||||
@ -79,7 +88,7 @@ private function restoreAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
@ -120,7 +129,7 @@ private function archiveAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
@ -172,7 +181,7 @@ private function forceDeleteAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
|
||||
@ -43,6 +43,27 @@ public function closeAddPoliciesModal(): void
|
||||
$this->unmountAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true) {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
|
||||
}
|
||||
|
||||
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
|
||||
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$refreshTable = Actions\Action::make('refreshTable')
|
||||
@ -77,7 +98,7 @@ public function table(Table $table): Table
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record): void {
|
||||
->action(function (mixed $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
@ -94,7 +115,7 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = [(int) $record->getKey()];
|
||||
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -173,14 +194,7 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = $records
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
@ -434,4 +448,68 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
|
||||
|
||||
return $query->whereIn('policy_type', $types);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedId = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereKey($recordId)
|
||||
->value('id');
|
||||
|
||||
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return (int) $resolvedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
||||
{
|
||||
$requestedIds = collect($recordKeys)
|
||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($requestedIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolvedIds = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereIn('id', $requestedIds)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (count($resolvedIds) !== count($requestedIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedIds;
|
||||
}
|
||||
|
||||
private function normalizeBackupItemKey(mixed $record): int
|
||||
{
|
||||
if ($record instanceof BackupItem) {
|
||||
return (int) $record->getKey();
|
||||
}
|
||||
|
||||
return is_numeric($record) ? (int) $record : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
@ -17,6 +20,7 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -24,21 +28,39 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use UnitEnum;
|
||||
|
||||
class EntraGroupResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = EntraGroup::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'display_name';
|
||||
|
||||
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 shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
@ -75,13 +97,8 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
? static::scopedUrl('view', ['record' => $record], static::panelTenantContext())
|
||||
: null)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
@ -176,7 +193,22 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->latest('id');
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key);
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultUrl(Model $record): string
|
||||
{
|
||||
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
|
||||
? $record->tenant
|
||||
: static::panelTenantContext();
|
||||
|
||||
return static::scopedUrl('view', ['record' => $record], $tenant);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -187,6 +219,20 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public static function scopedUrl(
|
||||
string $page = 'index',
|
||||
array $parameters = [],
|
||||
?Tenant $tenant = null,
|
||||
?string $panel = null,
|
||||
): string {
|
||||
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||
|
||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||
}
|
||||
|
||||
private static function groupType(EntraGroup $record): string
|
||||
{
|
||||
$groupTypes = $record->group_types;
|
||||
|
||||
@ -9,25 +9,47 @@
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
{
|
||||
protected static string $resource = EntraGroupResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
|
||||
return [
|
||||
Action::make('view_operations')
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
|
||||
->visible(fn (): bool => (bool) Tenant::current()),
|
||||
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||
->visible(fn (): bool => $tenant instanceof Tenant),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
@ -35,7 +57,7 @@ protected function getHeaderActions(): array
|
||||
->color('primary')
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$tenant = Tenant::current();
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
|
||||
@ -3,9 +3,49 @@
|
||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewEntraGroup extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EntraGroupResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EntraGroupResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! $tenant instanceof Tenant
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('view', $record)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
@ -0,0 +1,637 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Panel;
|
||||
use Filament\Resources\Pages\PageRegistration;
|
||||
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\Routing\Route;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceSnapshotResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = EvidenceSnapshot::class;
|
||||
|
||||
protected static ?string $slug = 'evidence';
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Evidence';
|
||||
|
||||
protected static ?int $navigationSort = 55;
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $record instanceof EvidenceSnapshot
|
||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Evidence dimensions')
|
||||
->schema([
|
||||
RepeatableEntry::make('items')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('dimension_key')->label('Dimension')
|
||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||
TextEntry::make('state')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||
TextEntry::make('source_kind')->label('Source')
|
||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
||||
ViewEntry::make('summary_payload_highlights')
|
||||
->label('Summary')
|
||||
->view('filament.infolists.entries.evidence-dimension-summary')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('summary_payload_raw')
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
'queued' => 'Queued',
|
||||
'generating' => 'Generating',
|
||||
'active' => 'Active',
|
||||
'superseded' => 'Superseded',
|
||||
'expired' => 'Expired',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
->label('View snapshot')
|
||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire snapshot')
|
||||
->color('danger')
|
||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||
->requiresConfirmation()
|
||||
->action(function (EvidenceSnapshot $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No evidence snapshots yet')
|
||||
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
||||
->emptyStateActions([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_first_snapshot')
|
||||
->label('Create first snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(fn (): mixed => static::executeGeneration([])),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
||||
'view' => new PageRegistration(
|
||||
page: Pages\ViewEvidenceSnapshot::class,
|
||||
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
||||
->whereNumber('record')
|
||||
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
||||
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
||||
{
|
||||
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
||||
|
||||
return match ($item->dimension_key) {
|
||||
'findings_summary' => static::findingsSummaryPresentation($payload),
|
||||
'permission_posture' => static::permissionPosturePresentation($payload),
|
||||
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
||||
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
||||
'operations_summary' => static::operationsSummaryPresentation($payload),
|
||||
default => static::genericSummaryPresentation($payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function findingsSummaryPresentation(array $payload): array
|
||||
{
|
||||
$count = (int) ($payload['count'] ?? 0);
|
||||
$openCount = (int) ($payload['open_count'] ?? 0);
|
||||
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
||||
'highlights' => [
|
||||
['label' => 'Findings', 'value' => (string) $count],
|
||||
['label' => 'Open findings', 'value' => (string) $openCount],
|
||||
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
||||
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
||||
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
||||
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function permissionPosturePresentation(array $payload): array
|
||||
{
|
||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||
$postureScore = $payload['posture_score'] ?? null;
|
||||
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
||||
'highlights' => [
|
||||
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
||||
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
||||
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
||||
],
|
||||
'items' => static::namedItemsFromArray(
|
||||
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
||||
'No missing permission details captured.'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function entraAdminRolesPresentation(array $payload): array
|
||||
{
|
||||
$roleCount = (int) ($payload['role_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
||||
'highlights' => [
|
||||
['label' => 'Role count', 'value' => (string) $roleCount],
|
||||
],
|
||||
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function baselineDriftPosturePresentation(array $payload): array
|
||||
{
|
||||
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
||||
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
||||
'highlights' => [
|
||||
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
||||
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
||||
],
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function operationsSummaryPresentation(array $payload): array
|
||||
{
|
||||
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
|
||||
'highlights' => [
|
||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function genericSummaryPresentation(array $payload): array
|
||||
{
|
||||
$highlights = collect($payload)
|
||||
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
||||
->take(6)
|
||||
->map(fn (mixed $value, string|int $key): array => [
|
||||
'label' => Str::headline((string) $key),
|
||||
'value' => static::stringifySummaryValue($value),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
||||
'highlights' => $highlights,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
||||
{
|
||||
if (! is_array($items) || $items === []) {
|
||||
return [$emptyFallback];
|
||||
}
|
||||
|
||||
$labels = collect($items)
|
||||
->map(function (mixed $item): ?string {
|
||||
if (is_string($item)) {
|
||||
return trim($item) !== '' ? $item : null;
|
||||
}
|
||||
|
||||
if (! is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
||||
$value = $item[$key] ?? null;
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $labels === [] ? [$emptyFallback] : $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function findingEntryLabel(array $entry): ?string
|
||||
{
|
||||
$title = $entry['title'] ?? null;
|
||||
$severity = $entry['severity'] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
|
||||
if (! is_string($title) || trim($title) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [trim($title)];
|
||||
|
||||
if (is_string($severity) && trim($severity) !== '') {
|
||||
$parts[] = Str::headline($severity);
|
||||
}
|
||||
|
||||
if (is_string($status) && trim($status) !== '') {
|
||||
$parts[] = Str::headline($status);
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function operationEntryLabel(array $entry): ?string
|
||||
{
|
||||
$type = $entry['type'] ?? null;
|
||||
|
||||
if (! is_string($type) || trim($type) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [static::operationTypeLabel($type)];
|
||||
|
||||
$stateLabel = static::operationEntryStateLabel($entry);
|
||||
|
||||
if ($stateLabel !== null) {
|
||||
$parts[] = $stateLabel;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
||||
{
|
||||
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
||||
}
|
||||
|
||||
private static function operationTypeLabel(string $type): string
|
||||
{
|
||||
$label = OperationCatalog::label($type);
|
||||
|
||||
return $label === 'Unknown operation' ? 'Operation' : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function operationEntryStateLabel(array $entry): ?string
|
||||
{
|
||||
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||
|
||||
return match ($status) {
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Completed->value => match ($outcome) {
|
||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
||||
default => 'Completed',
|
||||
},
|
||||
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
$value === null => '—',
|
||||
is_bool($value) => $value ? 'Yes' : 'No',
|
||||
is_scalar($value) => (string) $value,
|
||||
default => '—',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||
);
|
||||
|
||||
if (! $snapshot->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Snapshot already available')
|
||||
->body('A matching active snapshot already exists. No new run was started.')
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
->label('View snapshot')
|
||||
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Create snapshot queued')
|
||||
->body('The snapshot is being generated in the background.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ListEvidenceSnapshots extends ListRecords
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_snapshot')
|
||||
->label('Create snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
|
||||
->form([
|
||||
Section::make('Snapshot options')
|
||||
->schema([
|
||||
Toggle::make('allow_stale')
|
||||
->label('Allow stale dimensions')
|
||||
->default(false),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
||||
Actions\Action::make('view_review_pack')
|
||||
->label('View review pack')
|
||||
->icon('heroicon-o-document-text')
|
||||
->color('gray')
|
||||
->url(function (): ?string {
|
||||
$pack = $this->latestReviewPack();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
})
|
||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_snapshot')
|
||||
->label('Refresh evidence')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
||||
|
||||
Notification::make()->success()->title('Refresh evidence queued')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('expire_snapshot')
|
||||
->label('Expire snapshot')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
||||
$this->refreshFormData(['status', 'expires_at']);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function latestReviewPack(): ?ReviewPack
|
||||
{
|
||||
return $this->record->reviewPacks()
|
||||
->latest('created_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
488
app/Filament/Resources/FindingExceptionResource.php
Normal file
488
app/Filament/Resources/FindingExceptionResource.php
Normal file
@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionEvidenceReference;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingExceptionResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = FindingException::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Risk exceptions';
|
||||
|
||||
protected static ?int $navigationSort = 60;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $record instanceof FindingException
|
||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(static::relationshipsForView());
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Exception')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||||
TextEntry::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextEntry::make('governance_warning')
|
||||
->label('Governance warning')
|
||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||||
->columnSpanFull()
|
||||
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
||||
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
|
||||
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
|
||||
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
|
||||
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
|
||||
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
|
||||
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Decision history')
|
||||
->schema([
|
||||
RepeatableEntry::make('decisions')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('decision_type')->label('Decision'),
|
||||
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
|
||||
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
]),
|
||||
Section::make('Evidence references')
|
||||
->schema([
|
||||
RepeatableEntry::make('evidenceReferences')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('label')->label('Label'),
|
||||
TextEntry::make('source_type')->label('Source'),
|
||||
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
|
||||
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
|
||||
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('summary_payload')
|
||||
->label('Summary')
|
||||
->state(function (FindingExceptionEvidenceReference $record): ?string {
|
||||
if ($record->summary_payload === [] || $record->summary_payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
|
||||
})
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
])
|
||||
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('requested_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('governance_warning')
|
||||
->label('Governance warning')
|
||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('requester.name')
|
||||
->label('Requested by')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('owner.name')
|
||||
->label('Owner')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('review_due_at')
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('requested_at')
|
||||
->label('Requested')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||||
SelectFilter::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||
])
|
||||
->actions([
|
||||
Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->required()
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
->label('Renewal reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
DateTimePicker::make('review_due_at')
|
||||
->label('Review due at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Requested expiry')
|
||||
->seconds(false),
|
||||
Repeater::make('evidence_references')
|
||||
->label('Evidence references')
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_type')
|
||||
->label('Source type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_id')
|
||||
->label('Source ID')
|
||||
->maxLength(255),
|
||||
TextInput::make('source_fingerprint')
|
||||
->label('Fingerprint')
|
||||
->maxLength(255),
|
||||
DateTimePicker::make('measured_at')
|
||||
->label('Measured at')
|
||||
->seconds(false),
|
||||
])
|
||||
->defaultItems(0)
|
||||
->collapsed(),
|
||||
])
|
||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$service->renew($record, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Renewal request failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Renewal request submitted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('revoke_exception')
|
||||
->label('Revoke exception')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
->label('Revocation reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$service->revoke($record, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Exception revocation failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Exception revoked')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No exceptions match this view')
|
||||
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
|
||||
->emptyStateIcon('heroicon-o-shield-exclamation')
|
||||
->emptyStateActions([
|
||||
Action::make('open_findings')
|
||||
->label('Open findings')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(fn (): string => FindingResource::getUrl('index')),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFindingExceptions::route('/'),
|
||||
'view' => Pages\ViewFindingException::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string|array<int|string, mixed>>
|
||||
*/
|
||||
private static function relationshipsForView(): array
|
||||
{
|
||||
return [
|
||||
'tenant',
|
||||
'requester',
|
||||
'owner',
|
||||
'approver',
|
||||
'currentDecision',
|
||||
'decisions.actor',
|
||||
'evidenceReferences',
|
||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function tenantMemberOptions(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function findingSummary(FindingException $record): string
|
||||
{
|
||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
||||
|
||||
if (is_string($summary) && trim($summary) !== '') {
|
||||
return trim($summary);
|
||||
}
|
||||
|
||||
return 'Finding #'.$record->finding_id;
|
||||
}
|
||||
|
||||
private static function canManageRecord(FindingException $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $record->tenant instanceof Tenant
|
||||
&& $user->canAccessTenant($record->tenant)
|
||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||
}
|
||||
|
||||
private static function governanceWarning(FindingException $record): ?string
|
||||
{
|
||||
$finding = $record->relationLoaded('finding')
|
||||
? $record->finding
|
||||
: $record->finding()->withSubjectDisplayName()->first();
|
||||
|
||||
if (! $finding instanceof \App\Models\Finding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||
}
|
||||
|
||||
private static function governanceWarningColor(FindingException $record): string
|
||||
{
|
||||
$finding = $record->relationLoaded('finding')
|
||||
? $record->finding
|
||||
: $record->finding()->withSubjectDisplayName()->first();
|
||||
|
||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFindingExceptions extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingExceptionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('open_findings')
|
||||
->label('Open findings')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(FindingResource::getUrl('index')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ViewFindingException extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FindingExceptionResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return FindingExceptionResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('open_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(function (): ?string {
|
||||
$record = $this->getRecord();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}),
|
||||
Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||
->fillForm(fn (): array => [
|
||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->required()
|
||||
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
->label('Renewal reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
DateTimePicker::make('review_due_at')
|
||||
->label('Review due at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Requested expiry')
|
||||
->seconds(false),
|
||||
Repeater::make('evidence_references')
|
||||
->label('Evidence references')
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_type')
|
||||
->label('Source type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_id')
|
||||
->label('Source ID')
|
||||
->maxLength(255),
|
||||
TextInput::make('source_fingerprint')
|
||||
->label('Fingerprint')
|
||||
->maxLength(255),
|
||||
DateTimePicker::make('measured_at')
|
||||
->label('Measured at')
|
||||
->seconds(false),
|
||||
])
|
||||
->defaultItems(0)
|
||||
->collapsed(),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$service->renew($record, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Renewal request failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Renewal request submitted')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
||||
}),
|
||||
Action::make('revoke_exception')
|
||||
->label('Revoke exception')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
->label('Revocation reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$service->revoke($record, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Exception revocation failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Exception revoked')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function tenantMemberOptions(): array
|
||||
{
|
||||
$record = $this->getRecord();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function canManageRecord(): bool
|
||||
{
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
return $record instanceof FindingException
|
||||
&& $record->tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($record->tenant)
|
||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||
}
|
||||
}
|
||||
@ -2,14 +2,18 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -34,6 +38,8 @@
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -55,6 +61,9 @@
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -65,9 +74,18 @@ class FindingResource extends Resource
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -84,7 +102,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -101,7 +119,8 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof Finding) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey()
|
||||
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -162,17 +181,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->state(function (Finding $record): ?string {
|
||||
$state = $record->subject_display_name;
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
|
||||
TextEntry::make('subject_type')
|
||||
->label('Subject type')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)),
|
||||
@ -219,6 +228,62 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Risk governance')
|
||||
->schema([
|
||||
TextEntry::make('finding_governance_status')
|
||||
->label('Exception status')
|
||||
->badge()
|
||||
->state(fn (Finding $record): ?string => $record->findingException?->status)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('finding_governance_validity')
|
||||
->label('Validity')
|
||||
->badge()
|
||||
->state(function (Finding $record): ?string {
|
||||
if ($record->findingException instanceof FindingException) {
|
||||
return $record->findingException->current_validity_state;
|
||||
}
|
||||
|
||||
return (string) $record->status === Finding::STATUS_RISK_ACCEPTED
|
||||
? FindingException::VALIDITY_MISSING_SUPPORT
|
||||
: null;
|
||||
})
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('finding_governance_warning')
|
||||
->label('Governance warning')
|
||||
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
||||
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
||||
TextEntry::make('finding_governance_owner')
|
||||
->label('Exception owner')
|
||||
->state(fn (Finding $record): ?string => $record->findingException?->owner?->name)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('finding_governance_approver')
|
||||
->label('Approver')
|
||||
->state(fn (Finding $record): ?string => $record->findingException?->approver?->name)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('finding_governance_review_due')
|
||||
->label('Review due')
|
||||
->state(fn (Finding $record): mixed => $record->findingException?->review_due_at)
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('finding_governance_expires')
|
||||
->label('Expires')
|
||||
->state(fn (Finding $record): mixed => $record->findingException?->expires_at)
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Finding $record): bool => $record->findingException instanceof FindingException || (string) $record->status === Finding::STATUS_RISK_ACCEPTED),
|
||||
|
||||
Section::make('Evidence')
|
||||
->schema([
|
||||
TextEntry::make('redaction_integrity_note')
|
||||
@ -302,7 +367,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -336,7 +401,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.scope-tags-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -356,7 +421,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.assignments-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -591,16 +656,7 @@ public static function table(Table $table): Table
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->formatStateUsing(function (?string $state, Finding $record): ?string {
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
})
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||
Tables\Columns\TextColumn::make('subject_type')
|
||||
->label('Subject type')
|
||||
@ -725,7 +781,7 @@ public static function table(Table $table): Table
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -760,6 +816,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->triage($record, $tenant, $user);
|
||||
$triagedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -806,7 +863,7 @@ public static function table(Table $table): Table
|
||||
->searchable(),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -840,6 +897,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||
$assignedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -881,7 +939,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -914,6 +972,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->resolve($record, $tenant, $user, $reason);
|
||||
$resolvedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -955,7 +1014,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -988,6 +1047,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->close($record, $tenant, $user, $reason);
|
||||
$closedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -1015,79 +1075,6 @@ public static function table(Table $table): Table
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('risk_accept_selected')
|
||||
->label('Risk accept selected')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
->label('Risk acceptance reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = (string) ($data['closed_reason'] ?? '');
|
||||
|
||||
$acceptedCount = 0;
|
||||
$skippedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (! $record instanceof Finding) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $record->hasOpenStatus()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$workflow->riskAccept($record, $tenant, $user, $reason);
|
||||
$acceptedCount++;
|
||||
} catch (Throwable) {
|
||||
$failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
if ($failedCount > 0) {
|
||||
$body .= " Failed {$failedCount}.";
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk risk accept completed')
|
||||
->body($body)
|
||||
->status($failedCount > 0 ? 'warning' : 'success')
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No findings match this view')
|
||||
@ -1097,18 +1084,19 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->withSubjectDisplayName();
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
->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 resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->withSubjectDisplayName(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1184,7 +1172,9 @@ public static function workflowActions(): array
|
||||
static::assignAction(),
|
||||
static::resolveAction(),
|
||||
static::closeAction(),
|
||||
static::riskAcceptAction(),
|
||||
static::requestExceptionAction(),
|
||||
static::renewExceptionAction(),
|
||||
static::revokeExceptionAction(),
|
||||
static::reopenAction(),
|
||||
];
|
||||
}
|
||||
@ -1196,7 +1186,7 @@ public static function triageAction(): Actions\Action
|
||||
->label('Triage')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
@ -1222,7 +1212,7 @@ public static function startProgressAction(): Actions\Action
|
||||
->label('Start progress')
|
||||
->icon('heroicon-o-play')
|
||||
->color('info')
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
Finding::STATUS_TRIAGED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true))
|
||||
@ -1247,7 +1237,7 @@ public static function assignAction(): Actions\Action
|
||||
->label('Assign')
|
||||
->icon('heroicon-o-user-plus')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'assignee_user_id' => $record->assignee_user_id,
|
||||
'owner_user_id' => $record->owner_user_id,
|
||||
@ -1291,7 +1281,7 @@ public static function resolveAction(): Actions\Action
|
||||
->label('Resolve')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
@ -1326,6 +1316,7 @@ public static function closeAction(): Actions\Action
|
||||
->label('Close')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
@ -1353,36 +1344,153 @@ public static function closeAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function riskAcceptAction(): Actions\Action
|
||||
public static function requestExceptionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('risk_accept')
|
||||
->label('Risk accept')
|
||||
->icon('heroicon-o-shield-check')
|
||||
Actions\Action::make('request_exception')
|
||||
->label('Request exception')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
->label('Risk acceptance reason')
|
||||
->rows(3)
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
->label('Request reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
DateTimePicker::make('review_due_at')
|
||||
->label('Review due at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->seconds(false),
|
||||
Repeater::make('evidence_references')
|
||||
->label('Evidence references')
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_type')
|
||||
->label('Source type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_id')
|
||||
->label('Source ID')
|
||||
->maxLength(255),
|
||||
TextInput::make('source_fingerprint')
|
||||
->label('Fingerprint')
|
||||
->maxLength(255),
|
||||
DateTimePicker::make('measured_at')
|
||||
->label('Measured at')
|
||||
->seconds(false),
|
||||
])
|
||||
->defaultItems(0)
|
||||
->collapsed(),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding marked as risk accepted',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
(string) ($data['closed_reason'] ?? ''),
|
||||
),
|
||||
);
|
||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
||||
static::runExceptionRequestMutation($record, $data, $service);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function renewExceptionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->required()
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
->label('Renewal reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
DateTimePicker::make('review_due_at')
|
||||
->label('Review due at')
|
||||
->required()
|
||||
->seconds(false),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Requested expiry')
|
||||
->seconds(false),
|
||||
Repeater::make('evidence_references')
|
||||
->label('Evidence references')
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_type')
|
||||
->label('Source type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('source_id')
|
||||
->label('Source ID')
|
||||
->maxLength(255),
|
||||
TextInput::make('source_fingerprint')
|
||||
->label('Fingerprint')
|
||||
->maxLength(255),
|
||||
DateTimePicker::make('measured_at')
|
||||
->label('Measured at')
|
||||
->seconds(false),
|
||||
])
|
||||
->defaultItems(0)
|
||||
->collapsed(),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
||||
static::runExceptionRenewalMutation($record, $data, $service);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function revokeExceptionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('revoke_exception')
|
||||
->label('Revoke exception')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
->label('Revocation reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
||||
static::runExceptionRevocationMutation($record, $data, $service);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
@ -1395,7 +1503,7 @@ public static function reopenAction(): Actions\Action
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
@ -1415,7 +1523,8 @@ public static function reopenAction(): Actions\Action
|
||||
*/
|
||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -1431,6 +1540,15 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different workspace')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$callback($record, $tenant, $user);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
@ -1449,12 +1567,200 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$createdException = $service->request($record, $tenant, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Exception request failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Exception request submitted')
|
||||
->success()
|
||||
->actions([
|
||||
Actions\Action::make('view_exception')
|
||||
->label('View exception')
|
||||
->url(static::findingExceptionViewUrl($createdException, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Renewal request failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Renewal request submitted')
|
||||
->success()
|
||||
->actions([
|
||||
Actions\Action::make('view_exception')
|
||||
->label('View exception')
|
||||
->url(static::findingExceptionViewUrl($renewedException, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Exception revocation failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Exception revoked')
|
||||
->success()
|
||||
->actions([
|
||||
Actions\Action::make('view_exception')
|
||||
->label('View exception')
|
||||
->url(static::findingExceptionViewUrl($revokedException, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function freshWorkflowRecord(Finding $record): Finding
|
||||
{
|
||||
return static::resolveProtectedFindingRecordOrFail($record);
|
||||
}
|
||||
|
||||
private static function freshWorkflowStatus(Finding $record): string
|
||||
{
|
||||
return (string) static::freshWorkflowRecord($record)->status;
|
||||
}
|
||||
|
||||
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding
|
||||
{
|
||||
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
|
||||
if (! $resolvedRecord instanceof Finding) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private static function currentFindingException(Finding $record): ?FindingException
|
||||
{
|
||||
$finding = static::resolveProtectedFindingRecordOrFail($record);
|
||||
|
||||
return static::resolvedFindingException($finding);
|
||||
}
|
||||
|
||||
private static function resolvedFindingException(Finding $finding): ?FindingException
|
||||
{
|
||||
$exception = $finding->relationLoaded('findingException')
|
||||
? $finding->findingException
|
||||
: $finding->findingException()->with('currentDecision')->first();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$exception->loadMissing('currentDecision');
|
||||
|
||||
return $exception;
|
||||
}
|
||||
|
||||
private static function resolveCurrentFindingExceptionOrFail(Finding $record): FindingException
|
||||
{
|
||||
$exception = static::currentFindingException($record);
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
throw new InvalidArgumentException('This finding does not have an exception to manage.');
|
||||
}
|
||||
|
||||
return $exception;
|
||||
}
|
||||
|
||||
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, Tenant $tenant): string
|
||||
{
|
||||
$panelId = Filament::getCurrentPanel()?->getId();
|
||||
|
||||
if ($panelId === 'admin') {
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
|
||||
}
|
||||
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
private static function governanceWarning(Finding $finding): ?string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
->resolveWarningMessage($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function governanceWarningColor(Finding $finding): string
|
||||
{
|
||||
$exception = static::resolvedFindingException($finding);
|
||||
|
||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function tenantMemberOptions(): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
@ -11,6 +12,7 @@
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -21,13 +23,40 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
|
||||
try {
|
||||
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
@ -55,7 +84,7 @@ protected function getHeaderActions(): array
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -161,7 +190,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
@ -230,15 +259,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
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 = FindingResource::getEloquentQuery();
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
@ -288,6 +309,16 @@ protected function buildAllMatchingQuery(): Builder
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function filterIsActive(string $filterName): bool
|
||||
{
|
||||
$state = $this->getTableFilterState($filterName);
|
||||
|
||||
@ -8,11 +8,17 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewFinding extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return FindingResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
@ -23,6 +25,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
@ -36,6 +39,9 @@
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -48,6 +54,15 @@ class InventoryItemResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -60,7 +75,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -75,7 +90,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -175,7 +190,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
$service = app(DependencyQueryService::class);
|
||||
$resolver = app(DependencyTargetResolver::class);
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
@ -321,13 +336,15 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with('lastSeenRun');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
@ -11,6 +12,7 @@
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -28,8 +30,21 @@
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
@ -103,7 +118,7 @@ protected function getHeaderActions(): array
|
||||
->rules(['boolean'])
|
||||
->columnSpanFull(),
|
||||
Hidden::make('tenant_id')
|
||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||
->dehydrated(),
|
||||
])
|
||||
->visible(function (): bool {
|
||||
@ -112,7 +127,7 @@ protected function getHeaderActions(): array
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
@ -120,7 +135,7 @@ protected function getHeaderActions(): array
|
||||
return $user->canAccessTenant($tenant);
|
||||
})
|
||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -159,6 +174,8 @@ protected function getHeaderActions(): array
|
||||
],
|
||||
context: array_merge($computed['selection'], [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
'execution_authority_mode' => 'actor_bound',
|
||||
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
],
|
||||
|
||||
@ -4,8 +4,14 @@
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewInventoryItem extends ViewRecord
|
||||
{
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -157,14 +158,6 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -228,14 +221,8 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
||||
? (int) $tenant->getKey()
|
||||
: null;
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
||||
->whereNotNull('initiator_name')
|
||||
->select('initiator_name')
|
||||
->distinct()
|
||||
@ -268,6 +255,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||
$targetScope = static::targetScopeDisplay($record);
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||
: null;
|
||||
|
||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||
@ -314,7 +304,34 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
badge: $factory->statusBadge(
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
$referencedTenantLifecycle->presentation->badgeColor,
|
||||
$referencedTenantLifecycle->presentation->badgeIcon,
|
||||
$referencedTenantLifecycle->presentation->badgeIconColor,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||
: null,
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||
static::blockedExecutionReasonCode($record) !== null
|
||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||
: null,
|
||||
static::blockedExecutionDetail($record) !== null
|
||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||
: null,
|
||||
static::blockedExecutionSource($record) !== null
|
||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||
: null,
|
||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
||||
])),
|
||||
),
|
||||
@ -361,7 +378,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$factory->viewSection(
|
||||
id: 'failures',
|
||||
kind: 'operational_context',
|
||||
title: 'Failures',
|
||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $record->failure_summary ?? []],
|
||||
),
|
||||
@ -443,6 +460,51 @@ private static function summaryCountFacts(
|
||||
);
|
||||
}
|
||||
|
||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||
{
|
||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($record->failure_summary, '0.reason_code');
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||
}
|
||||
|
||||
private static function blockedExecutionDetail(OperationRun $record): ?string
|
||||
{
|
||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = data_get($record->failure_summary, '0.message');
|
||||
|
||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||
}
|
||||
|
||||
private static function blockedExecutionSource(OperationRun $record): ?string
|
||||
{
|
||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$blockedBy = $context['blocked_by'] ?? null;
|
||||
|
||||
if (! is_string($blockedBy) || trim($blockedBy) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (trim($blockedBy)) {
|
||||
'queued_execution_legitimacy' => 'Execution legitimacy revalidation',
|
||||
default => ucfirst(str_replace('_', ' ', trim($blockedBy))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
@ -554,7 +616,7 @@ private static function verificationReportViewData(OperationRun $record): array
|
||||
$previousRunUrl = null;
|
||||
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyResource\Pages;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Jobs\BulkPolicyDeleteJob;
|
||||
@ -34,6 +37,7 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -52,17 +56,32 @@
|
||||
|
||||
class PolicyResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static ?string $model = Policy::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -98,7 +117,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
->modalHeading('Sync policies from Intune')
|
||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||
->action(function (Pages\ListPolicies $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
@ -488,7 +507,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
@ -509,7 +528,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
@ -523,7 +542,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -569,7 +588,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveVisibility()
|
||||
@ -586,7 +605,7 @@ public static function table(Table $table): Table
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -639,7 +658,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -678,7 +697,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -761,7 +780,7 @@ public static function table(Table $table): Table
|
||||
return ! in_array($value, [null, 'ignored'], true);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -843,7 +862,7 @@ public static function table(Table $table): Table
|
||||
return $value === 'ignored';
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
|
||||
@ -914,7 +933,7 @@ public static function table(Table $table): Table
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -995,16 +1014,25 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->withCount('versions')
|
||||
->with([
|
||||
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()
|
||||
->withCount('versions')
|
||||
->with([
|
||||
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -3,12 +3,20 @@
|
||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPolicies extends ListRecords
|
||||
{
|
||||
protected static string $resource = PolicyResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
@ -22,4 +30,14 @@ protected function getTableEmptyStateActions(): array
|
||||
PolicyResource::makeSyncAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: [],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ViewPolicy extends ViewRecord
|
||||
@ -24,6 +25,11 @@ class ViewPolicy extends ViewRecord
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return PolicyResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [$this->makeCaptureSnapshotAction()];
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -26,8 +28,23 @@
|
||||
|
||||
class VersionsRelationManager extends RelationManager
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $relationship = 'versions';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && $name === 'restore_to_intune' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']);
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
@ -52,8 +69,9 @@ public function table(Table $table): Table
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true),
|
||||
])
|
||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||
$tenant = Tenant::current();
|
||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -110,7 +128,7 @@ public function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -130,7 +148,7 @@ public function table(Table $table): Table
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -175,4 +193,26 @@ public function table(Table $table): Table
|
||||
->emptyStateHeading('No versions captured')
|
||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||
{
|
||||
$recordId = $record instanceof PolicyVersion
|
||||
? (int) $record->getKey()
|
||||
: (is_numeric($record) ? (int) $record : 0);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedRecord = $policy->versions()
|
||||
->where('tenant_id', (int) $policy->tenant_id)
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof PolicyVersion) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||
@ -39,6 +42,7 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -57,17 +61,32 @@
|
||||
|
||||
class PolicyVersionResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static ?string $model = PolicyVersion::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -274,7 +293,7 @@ public static function table(Table $table): Table
|
||||
return $fields;
|
||||
})
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -353,7 +372,7 @@ public static function table(Table $table): Table
|
||||
->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();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -437,7 +456,7 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -546,7 +565,7 @@ public static function table(Table $table): Table
|
||||
->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();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -563,7 +582,7 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -580,7 +599,7 @@ public static function table(Table $table): Table
|
||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -605,7 +624,7 @@ public static function table(Table $table): Table
|
||||
return null;
|
||||
})
|
||||
->action(function (PolicyVersion $record) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -877,8 +896,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::currentOrFail();
|
||||
$tenantId = $tenant->getKey();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$user = auth()->user();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
@ -888,8 +906,7 @@ public static function getEloquentQuery(): Builder
|
||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
@ -903,6 +920,36 @@ public static function getEloquentQuery(): Builder
|
||||
->with('policy');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$user = auth()->user();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$canSeeBaselinePurposeEvidence = $user instanceof User
|
||||
&& (
|
||||
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|
||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
);
|
||||
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereNull('capture_purpose')
|
||||
->orWhereNotIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->with('policy'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
|
||||
@ -3,12 +3,24 @@
|
||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPolicyVersions extends ListRecords
|
||||
{
|
||||
protected static string $resource = PolicyVersionResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewPolicyVersion extends ViewRecord
|
||||
{
|
||||
@ -16,6 +17,11 @@ class ViewPolicyVersion extends ViewRecord
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return PolicyVersionResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -11,7 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -21,6 +22,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -30,9 +32,10 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -49,6 +52,8 @@
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
@ -147,9 +152,7 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
return static::resolveTenantByExternalId($contextTenantExternalId);
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
||||
@ -196,10 +199,10 @@ public static function resolveContextTenantExternalId(): ?string
|
||||
}
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($filamentTenant instanceof Tenant) {
|
||||
return (string) $filamentTenant->external_id;
|
||||
if ($tenant instanceof Tenant) {
|
||||
return (string) $tenant->external_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -329,10 +332,10 @@ private static function applyMembershipScope(Builder $query): Builder
|
||||
$user = auth()->user();
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
$filamentTenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($filamentTenant instanceof Tenant) {
|
||||
$workspaceId = (int) $filamentTenant->workspace_id;
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,6 +404,96 @@ private static function sanitizeErrorMessage(?string $value): ?string
|
||||
return Str::limit($normalized, 120);
|
||||
}
|
||||
|
||||
private static function providerConnectionTypeLabel(?ProviderConnection $record): string
|
||||
{
|
||||
$connectionType = $record?->connection_type;
|
||||
|
||||
if ($connectionType instanceof ProviderConnectionType && $connectionType === ProviderConnectionType::Dedicated) {
|
||||
return 'Dedicated connection';
|
||||
}
|
||||
|
||||
return 'Platform connection';
|
||||
}
|
||||
|
||||
private static function effectiveAppId(?ProviderConnection $record): string
|
||||
{
|
||||
$effectiveAppId = $record?->effectiveAppId();
|
||||
|
||||
return filled($effectiveAppId) ? (string) $effectiveAppId : 'Effective app pending review';
|
||||
}
|
||||
|
||||
private static function credentialSourceLabel(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Managed centrally by platform';
|
||||
}
|
||||
|
||||
$effectiveApp = $record->effectiveAppMetadata();
|
||||
|
||||
return match ($effectiveApp['source'] ?? null) {
|
||||
'dedicated_credential' => 'Dedicated credential',
|
||||
'review_required' => 'Legacy identity review required',
|
||||
default => 'Managed centrally by platform',
|
||||
};
|
||||
}
|
||||
|
||||
private static function migrationReviewLabel(?ProviderConnection $record): string
|
||||
{
|
||||
return $record?->requiresMigrationReview() ? 'Review required' : 'Clear';
|
||||
}
|
||||
|
||||
private static function migrationReviewDescription(?ProviderConnection $record): ?string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$metadata = $record->legacyIdentityMetadata();
|
||||
$source = $metadata['legacy_identity_classification_source'] ?? null;
|
||||
$result = $metadata['legacy_identity_result'] ?? null;
|
||||
|
||||
if (! $record->requiresMigrationReview()) {
|
||||
return filled($source) ? sprintf('Classified via %s as %s.', $source, $result ?? 'platform') : null;
|
||||
}
|
||||
|
||||
$signals = $metadata['legacy_identity_signals'] ?? [];
|
||||
$tenantClientId = $signals['tenant_client_id'] ?? null;
|
||||
$credentialClientId = $signals['credential_client_id'] ?? null;
|
||||
|
||||
if (filled($tenantClientId) && filled($credentialClientId)) {
|
||||
return sprintf('Legacy tenant app %s conflicts with dedicated app %s.', $tenantClientId, $credentialClientId);
|
||||
}
|
||||
|
||||
return 'Legacy app evidence conflicts with the current connection and needs explicit review.';
|
||||
}
|
||||
|
||||
private static function consentStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'required' => 'Required',
|
||||
'granted' => 'Granted',
|
||||
'failed' => 'Failed',
|
||||
'revoked' => 'Revoked',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private static function verificationStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'pending' => 'Pending',
|
||||
'healthy' => 'Healthy',
|
||||
'degraded' => 'Degraded',
|
||||
'blocked' => 'Blocked',
|
||||
'error' => 'Error',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -418,6 +511,15 @@ public static function form(Schema $schema): Schema
|
||||
->maxLength(255)
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->rules(['uuid']),
|
||||
Placeholder::make('connection_type_display')
|
||||
->label('Connection type')
|
||||
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||
Placeholder::make('platform_app_id_display')
|
||||
->label('Effective app ID')
|
||||
->content(fn (?ProviderConnection $record): string => static::effectiveAppId($record)),
|
||||
Placeholder::make('effective_app_source_display')
|
||||
->label('Effective app source')
|
||||
->content(fn (?ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||
Toggle::make('is_default')
|
||||
->label('Default connection')
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
@ -427,6 +529,12 @@ public static function form(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
Placeholder::make('consent_status_display')
|
||||
->label('Consent')
|
||||
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
||||
Placeholder::make('verification_status_display')
|
||||
->label('Verification')
|
||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||
TextInput::make('status')
|
||||
->label('Status')
|
||||
->disabled()
|
||||
@ -435,17 +543,69 @@ public static function form(Schema $schema): Schema
|
||||
->label('Health')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Placeholder::make('migration_review_status_display')
|
||||
->label('Migration review')
|
||||
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Connection')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('display_name')
|
||||
->label('Display name'),
|
||||
Infolists\Components\TextEntry::make('provider')
|
||||
->label('Provider'),
|
||||
Infolists\Components\TextEntry::make('entra_tenant_id')
|
||||
->label('Entra tenant ID')
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('connection_type')
|
||||
->label('Connection type')
|
||||
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||
? 'Dedicated connection'
|
||||
: 'Platform connection'),
|
||||
Infolists\Components\TextEntry::make('effective_app_id')
|
||||
->label('Effective app ID')
|
||||
->state(fn (ProviderConnection $record): string => static::effectiveAppId($record))
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('effective_app_source')
|
||||
->label('Effective app source')
|
||||
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('consent_status')
|
||||
->label('Consent')
|
||||
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
|
||||
Infolists\Components\TextEntry::make('verification_status')
|
||||
->label('Verification')
|
||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->label('Status'),
|
||||
Infolists\Components\TextEntry::make('health_status')
|
||||
->label('Health'),
|
||||
Infolists\Components\TextEntry::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$query->with('tenant');
|
||||
$query->with(['tenant', 'credential']);
|
||||
|
||||
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||
|
||||
@ -488,6 +648,13 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||
Tables\Columns\TextColumn::make('connection_type')
|
||||
->label('Connection type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||
? 'Dedicated'
|
||||
: 'Platform')
|
||||
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
@ -502,6 +669,12 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||
Tables\Columns\TextColumn::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
|
||||
->color(fn (bool $state): string => $state ? 'warning' : 'success')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
@ -920,31 +1093,32 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
Actions\Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Client secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
@ -957,12 +1131,16 @@ public static function table(Table $table): Table
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.credentials_updated',
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.resource_table',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -974,12 +1152,138 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Credentials updated')
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (ProviderConnection $record): string {
|
||||
$payload = $record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $record->credential()->exists())
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($record);
|
||||
|
||||
$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.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.resource_table',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
|
||||
@ -6,6 +6,11 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
@ -25,12 +30,31 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
'display_name' => $data['display_name'],
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'is_default' => false,
|
||||
];
|
||||
}
|
||||
@ -57,6 +81,7 @@ protected function afterCreate(): void
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'connection_type' => $record->connection_type->value,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
|
||||
@ -11,13 +11,14 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -310,32 +311,33 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Client secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
@ -348,12 +350,16 @@ protected function getHeaderActions(): array
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.credentials_updated',
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.edit_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -365,13 +371,143 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Credentials updated')
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Stores a replacement dedicated client secret and refreshes dedicated identity state.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (ProviderConnection $record): string {
|
||||
$payload = $record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $record->credential()->exists())
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Reverts the connection to the platform-managed identity and removes any dedicated credential.')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($record);
|
||||
|
||||
$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.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.edit_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
|
||||
@ -7,14 +7,26 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProviderConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProviderConnectionResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: 'tenant',
|
||||
tenantAttribute: 'external_id',
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
@ -207,9 +219,7 @@ private function resolveTenantExternalIdForCreateAction(): ?string
|
||||
return $requested;
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
|
||||
return ProviderConnectionResource::resolveContextTenantExternalId();
|
||||
}
|
||||
|
||||
private function resolveTenantForCreateAction(): ?Tenant
|
||||
@ -227,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
||||
|
||||
public function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
return 'No provider connections found';
|
||||
return 'No Microsoft connections found';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
|
||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateActions(): array
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
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 App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewProviderConnection extends ViewRecord
|
||||
@ -15,6 +23,24 @@ class ViewProviderConnection extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('grant_admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(function (): ?string {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||
: null;
|
||||
})
|
||||
->visible(function (): bool {
|
||||
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -23,6 +49,201 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('enable_dedicated_override')
|
||||
->label('Enable dedicated override')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||
->visible(fn (): bool => $this->record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $this->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.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $this->record->getKey(),
|
||||
'provider' => $this->record->provider,
|
||||
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.view_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Dedicated app (client) ID')
|
||||
->default(function (): string {
|
||||
$payload = $this->record->credential?->payload;
|
||||
|
||||
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||
})
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('client_secret')
|
||||
->label('Dedicated client secret')
|
||||
->password()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $this->record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated
|
||||
&& $this->record->credential()->exists())
|
||||
->action(function (ProviderConnectionMutationService $mutations): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->deleteDedicatedCredential($this->record);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('revert_to_platform')
|
||||
->label('Revert to platform')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||
->action(function (ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->revertToPlatform($this->record);
|
||||
|
||||
$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.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $this->record->getKey(),
|
||||
'provider' => $this->record->provider,
|
||||
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.view_page',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('Manage dedicated override')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
@ -65,6 +67,9 @@
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -84,7 +89,7 @@ public static function shouldRegisterNavigation(): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -105,7 +110,7 @@ public static function form(Schema $schema): Schema
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
@ -145,7 +150,7 @@ public static function form(Schema $schema): Schema
|
||||
->schema(function (Get $get): array {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -199,7 +204,7 @@ public static function form(Schema $schema): Schema
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
@ -207,7 +212,7 @@ public static function form(Schema $schema): Schema
|
||||
->visible(function (Get $get): bool {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return false;
|
||||
@ -239,18 +244,44 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
||||
->with('backupSet');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('backupSet')
|
||||
->when(
|
||||
$tenantId !== null,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId),
|
||||
)
|
||||
->when(
|
||||
$tenantId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
|
||||
{
|
||||
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
|
||||
if (! $resolvedRecord instanceof RestoreRun) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
protected static function resolveProtectedRestoreRunIds(Collection $records): array
|
||||
{
|
||||
return $records
|
||||
->map(function (mixed $record): int {
|
||||
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
|
||||
|
||||
return (int) $resolvedRecord->getKey();
|
||||
})
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -265,7 +296,7 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
@ -290,7 +321,7 @@ public static function getWizardSteps(): array
|
||||
backupSetId: $get('backup_set_id'),
|
||||
scopeMode: 'all',
|
||||
selectedItemIds: null,
|
||||
tenant: Tenant::current(),
|
||||
tenant: static::resolveTenantContextForCurrentPanel(),
|
||||
));
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
@ -317,7 +348,7 @@ public static function getWizardSteps(): array
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
@ -357,7 +388,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$set('group_mapping', static::groupMappingPlaceholders(
|
||||
backupSetId: $backupSetId,
|
||||
@ -408,7 +439,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$scopeMode = $get('scope_mode') ?? 'all';
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -477,7 +508,7 @@ public static function getWizardSteps(): array
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
@ -486,7 +517,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$scopeMode = $get('scope_mode') ?? 'all';
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return false;
|
||||
@ -527,7 +558,7 @@ public static function getWizardSteps(): array
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||
->action(function (Get $get, Set $set): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
@ -625,7 +656,7 @@ public static function getWizardSteps(): array
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||
->action(function (Get $get, Set $set): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
@ -704,7 +735,7 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Placeholder::make('confirm_tenant_label')
|
||||
->label('Tenant hard-confirm label')
|
||||
->content(function (): string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return '';
|
||||
@ -741,7 +772,7 @@ public static function getWizardSteps(): array
|
||||
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||
->in(function (): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return [];
|
||||
@ -755,7 +786,7 @@ public static function getWizardSteps(): array
|
||||
'in' => 'Tenant hard-confirm does not match.',
|
||||
])
|
||||
->helperText(function (): string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return '';
|
||||
@ -843,6 +874,8 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant) {
|
||||
@ -861,7 +894,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -874,6 +907,8 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||
|
||||
if (! $record->isDeletable()) {
|
||||
Notification::make()
|
||||
->title('Restore run cannot be archived')
|
||||
@ -902,7 +937,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -915,6 +950,8 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
@ -933,7 +970,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->preserveVisibility()
|
||||
@ -972,10 +1009,10 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1042,10 +1079,10 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
||||
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1132,10 +1169,10 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1276,7 +1313,7 @@ private static function typeMeta(?string $type): array
|
||||
*/
|
||||
private static function restoreItemOptionData(?int $backupSetId): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [
|
||||
@ -1347,7 +1384,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
*/
|
||||
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -1399,7 +1436,7 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
|
||||
public static function createRestoreRun(array $data): RestoreRun
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -1691,6 +1728,8 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||
'execution_authority_mode' => 'actor_bound',
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
],
|
||||
initiator: $initiator,
|
||||
);
|
||||
@ -1922,6 +1961,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
\App\Services\Intune\AuditLogger $auditLogger,
|
||||
HasTable $livewire
|
||||
) {
|
||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||
$tenant = $record->tenant;
|
||||
$backupSet = $record->backupSet;
|
||||
|
||||
@ -2089,6 +2129,8 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
'restore_run_id' => (int) $newRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
||||
'execution_authority_mode' => 'actor_bound',
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
],
|
||||
initiator: $initiator,
|
||||
);
|
||||
@ -2165,7 +2207,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
OperationUxPresenter::queuedToast('restore.execute')
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -2181,7 +2223,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
@ -2205,7 +2247,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'Tenant unavailable';
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -17,12 +18,13 @@
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
{
|
||||
use HasWizard;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = RestoreRunResource::class;
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -60,7 +62,7 @@ protected function afterFill(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
|
||||
@ -3,12 +3,42 @@
|
||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class ListRestoreRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = RestoreRunResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'forceDelete', 'rerun'], true)) {
|
||||
try {
|
||||
RestoreRunResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
|
||||
@ -4,8 +4,14 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewRestoreRun extends ViewRecord
|
||||
{
|
||||
protected static string $resource = RestoreRunResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return RestoreRunResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
@ -164,6 +166,21 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||
TextEntry::make('tenantReview.id')
|
||||
->label('Tenant review')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => $record->tenantReview && $record->tenant
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.review_status')
|
||||
->label('Review status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
@ -177,6 +194,33 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Evidence snapshot')
|
||||
->schema([
|
||||
TextEntry::make('summary.evidence_resolution.outcome')
|
||||
->label('Resolution')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
||||
->label('Snapshot fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -201,6 +245,10 @@ public static function table(Table $table): Table
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('tenantReview.id')
|
||||
->label('Review')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@ -331,7 +379,23 @@ public static function executeGeneration(array $data): void
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
try {
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||
$reasons = $exception->result->reasons;
|
||||
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(match ($exception->result->outcome) {
|
||||
'missing_snapshot' => 'Create snapshot required',
|
||||
'snapshot_ineligible' => 'Snapshot is not eligible',
|
||||
default => 'Unable to generate review pack',
|
||||
})
|
||||
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
use App\Models\EntraRoleDefinition;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
@ -21,7 +23,11 @@
|
||||
use App\Services\Intune\RbacOnboardingService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Tenants\TenantActionPolicySurface;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -33,6 +39,12 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Tenants\TenantActionDescriptor;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -76,6 +88,11 @@ class TenantResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
/**
|
||||
* @var array<string, Collection<int, TenantActionDescriptor>>
|
||||
*/
|
||||
protected static array $tenantActionCatalogCache = [];
|
||||
|
||||
/**
|
||||
* Tenant creation is handled exclusively by the onboarding wizard.
|
||||
* The CRUD create page has been removed.
|
||||
@ -129,9 +146,10 @@ public static function canDeleteAny(): bool
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->withListRowPrimaryActionLimit(2)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||
@ -207,6 +225,18 @@ public static function getEloquentQuery(): Builder
|
||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||
}
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
|
||||
return static::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
|
||||
static::getEloquentQuery(),
|
||||
(new Tenant)->getTable(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -242,20 +272,23 @@ public static function table(Table $table): Table
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_current')
|
||||
->label('Current')
|
||||
->boolean(),
|
||||
->boolean()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('app_status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->since()
|
||||
@ -284,11 +317,56 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||
->color('danger')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||
Actions\Action::make('related_onboarding_overflow')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
@ -415,46 +493,9 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
@ -475,7 +516,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
@ -592,48 +633,6 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
static::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
@ -838,6 +837,10 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||
Infolists\Components\TextEntry::make('lifecycle_summary')
|
||||
->label('Lifecycle summary')
|
||||
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('app_status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
@ -936,7 +939,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Integration')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('admin_consent_url')
|
||||
->label('Admin consent URL')
|
||||
->label('Grant admin consent URL')
|
||||
->state(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (?string $state) => filled($state))
|
||||
->copyable()
|
||||
@ -1013,6 +1016,216 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
protected static function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||
{
|
||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||
}
|
||||
|
||||
public static function tenantOperability(): TenantOperabilityService
|
||||
{
|
||||
return app(TenantOperabilityService::class);
|
||||
}
|
||||
|
||||
public static function tenantActionPolicy(): TenantActionPolicySurface
|
||||
{
|
||||
return app(TenantActionPolicySurface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantActionDescriptor>
|
||||
*/
|
||||
public static function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): Collection
|
||||
{
|
||||
$cacheKey = static::tenantActionCatalogCacheKey($tenant, $surface);
|
||||
|
||||
if (! array_key_exists($cacheKey, static::$tenantActionCatalogCache)) {
|
||||
static::$tenantActionCatalogCache[$cacheKey] = collect(static::tenantActionPolicy()->catalogForTenant($tenant, $surface))->values();
|
||||
}
|
||||
|
||||
return static::$tenantActionCatalogCache[$cacheKey];
|
||||
}
|
||||
|
||||
public static function lifecycleActionDescriptor(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): ?TenantActionDescriptor
|
||||
{
|
||||
return static::tenantActionDescriptorForSurface($tenant, $surface, 'restore')
|
||||
?? static::tenantActionDescriptorForSurface($tenant, $surface, 'archive');
|
||||
}
|
||||
|
||||
public static function tenantIndexPrimaryAction(Tenant $tenant): ?TenantActionDescriptor
|
||||
{
|
||||
$catalog = static::tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow);
|
||||
|
||||
return $catalog[1] ?? null;
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
|
||||
{
|
||||
return static::tenantActionPolicy()->relatedOnboardingDraft($tenant);
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraftAction(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?TenantActionDescriptor
|
||||
{
|
||||
return static::tenantActionDescriptorForSurface($tenant, $surface, 'related_onboarding');
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraftActionLabel(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?string
|
||||
{
|
||||
return static::relatedOnboardingDraftAction($tenant, $surface)?->label;
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$draft = static::relatedOnboardingDraft($tenant);
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||
}
|
||||
|
||||
public static function verificationActionVisible(Tenant $tenant): bool
|
||||
{
|
||||
$outcome = static::verificationReadinessOutcome($tenant);
|
||||
|
||||
return $outcome->allowed || $outcome->isDeniedForCapability();
|
||||
}
|
||||
|
||||
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return static::tenantOperability()->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||
actor: $user instanceof User ? $user : null,
|
||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
);
|
||||
}
|
||||
|
||||
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
||||
{
|
||||
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
||||
->first(fn (TenantActionDescriptor $descriptor): bool => $descriptor->key === $key);
|
||||
|
||||
return $descriptor instanceof TenantActionDescriptor ? $descriptor : null;
|
||||
}
|
||||
|
||||
private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantActionSurface $surface): string
|
||||
{
|
||||
$relatedDraft = static::relatedOnboardingDraft($tenant);
|
||||
|
||||
return implode(':', [
|
||||
(string) (auth()->id() ?? 'guest'),
|
||||
$surface->value,
|
||||
(string) ($tenant->getKey() ?? 'missing'),
|
||||
(string) $tenant->status,
|
||||
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
|
||||
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
|
||||
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
|
||||
(string) ($relatedDraft?->workflowStatus()->value ?? 'no-draft-status'),
|
||||
(string) ($relatedDraft?->lifecycleState()->value ?? 'no-draft-lifecycle-state'),
|
||||
(string) ($relatedDraft?->updated_at?->getTimestamp() ?? 'no-draft-updated-at'),
|
||||
(string) ($relatedDraft?->completed_at?->getTimestamp() ?? 'no-draft-completed-at'),
|
||||
(string) ($relatedDraft?->cancelled_at?->getTimestamp() ?? 'no-draft-cancelled-at'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$descriptor = static::lifecycleActionDescriptor($record);
|
||||
|
||||
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'archive') {
|
||||
Notification::make()
|
||||
->title('Archive unavailable')
|
||||
->body('Only active tenants can be archived from tenant management surfaces.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: AuditActionId::TenantArchived,
|
||||
actor: $user,
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
|
||||
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$descriptor = static::lifecycleActionDescriptor($record);
|
||||
|
||||
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'restore') {
|
||||
Notification::make()
|
||||
->title('Restore unavailable')
|
||||
->body('Only archived tenants can be restored from tenant management surfaces.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: AuditActionId::TenantRestored,
|
||||
actor: $user,
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
|
||||
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
@ -1231,36 +1444,6 @@ public static function rbacAction(): Actions\Action
|
||||
}
|
||||
|
||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$tenantId = $tenant->graphTenantId();
|
||||
$clientId = $tenant->app_client_id;
|
||||
|
||||
if (! is_string($clientId) || trim($clientId) === '') {
|
||||
$clientId = static::resolveProviderClientIdForConsent($tenant);
|
||||
}
|
||||
$redirectUri = route('admin.consent.callback');
|
||||
$state = sprintf('tenantpilot|%s', $tenant->id);
|
||||
|
||||
if (! $tenantId || ! $clientId || ! $redirectUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Admin consent should use `.default` so the tenant consents to the app's configured
|
||||
// application permissions. Keeping the URL short also avoids edge cases where a long
|
||||
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
|
||||
$scopes = 'https://graph.microsoft.com/.default';
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'state' => $state,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => $scopes,
|
||||
]);
|
||||
|
||||
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
|
||||
}
|
||||
|
||||
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
|
||||
{
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -1273,21 +1456,11 @@ private static function resolveProviderClientIdForConsent(Tenant $tenant): ?stri
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $connection->credential?->payload;
|
||||
|
||||
if (! is_array($payload)) {
|
||||
try {
|
||||
return app(AdminConsentUrlFactory::class)->make($connection, sprintf('tenantpilot|%s', $tenant->id));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = $payload['client_id'] ?? null;
|
||||
|
||||
if (! is_string($clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim($clientId);
|
||||
|
||||
return $clientId !== '' ? $clientId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1340,10 +1513,21 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
|
||||
public static function entraUrl(Tenant $tenant): ?string
|
||||
{
|
||||
if ($tenant->app_client_id) {
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$effectiveAppId = $connection instanceof ProviderConnection
|
||||
? $connection->effectiveAppId()
|
||||
: null;
|
||||
|
||||
if (filled($effectiveAppId)) {
|
||||
return sprintf(
|
||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
|
||||
$tenant->app_client_id
|
||||
$effectiveAppId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
@ -18,14 +20,40 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
|
||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
TenantResource::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->tooltip('You do not have permission to restore tenants.')
|
||||
->preserveVisibility()
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('archive')
|
||||
->label('Archive')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
|
||||
->color('danger')
|
||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record): void {
|
||||
$record->delete();
|
||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
|
||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
TenantResource::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -13,10 +19,7 @@ class ListTenants extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('add_tenant')
|
||||
->label('Add tenant')
|
||||
->icon('heroicon-m-plus')
|
||||
->url(route('admin.onboarding'))
|
||||
$this->makeOnboardingEntryAction()
|
||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||
];
|
||||
}
|
||||
@ -24,10 +27,40 @@ protected function getHeaderActions(): array
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('add_tenant')
|
||||
->label('Add tenant')
|
||||
->icon('heroicon-m-plus')
|
||||
->url(route('admin.onboarding')),
|
||||
$this->makeOnboardingEntryAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function makeOnboardingEntryAction(): Actions\Action
|
||||
{
|
||||
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
|
||||
|
||||
return Actions\Action::make('add_tenant')
|
||||
->label($descriptor->label)
|
||||
->icon($descriptor->icon)
|
||||
->url(route('admin.onboarding'));
|
||||
}
|
||||
|
||||
private function accessibleResumableDraftCount(): int
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace)->count();
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -63,8 +64,13 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
||||
@ -81,7 +87,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
@ -264,34 +270,34 @@ protected function getHeaderActions(): array
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant')
|
||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
TenantResource::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
|
||||
->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->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();
|
||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
|
||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
TenantResource::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
|
||||
562
app/Filament/Resources/TenantReviewResource.php
Normal file
562
app/Filament/Resources/TenantReviewResource.php
Normal file
@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\TenantReviewSection;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
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\Support\Enums\TextSize;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantReviewResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $model = TenantReview::class;
|
||||
|
||||
protected static ?string $slug = 'reviews';
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Reviews';
|
||||
|
||||
protected static ?int $navigationSort = 45;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('view', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
||||
->latest('generated_at')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Evidence snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('currentExportReviewPack.id')
|
||||
->label('Current export')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Executive posture')
|
||||
->schema([
|
||||
ViewEntry::make('review_summary')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.tenant-review-summary')
|
||||
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Sections')
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
ViewEntry::make('section_payload')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.tenant-review-section')
|
||||
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->searchable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(collect(TenantReviewStatus::cases())
|
||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||
fn (TenantReview $record): TenantReview => $record,
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No tenant reviews yet')
|
||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||
->emptyStateActions([
|
||||
static::makeCreateReviewAction(
|
||||
name: 'create_first_review',
|
||||
label: 'Create first review',
|
||||
icon: 'heroicon-o-plus',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTenantReviews::route('/'),
|
||||
'view' => Pages\ViewTenantReview::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function makeCreateReviewAction(
|
||||
string $name = 'create_review',
|
||||
string $label = 'Create review',
|
||||
string $icon = 'heroicon-o-plus',
|
||||
): Actions\Action {
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
->form([
|
||||
Section::make('Evidence basis')
|
||||
->schema([
|
||||
Select::make('evidence_snapshot_id')
|
||||
->label('Evidence snapshot')
|
||||
->required()
|
||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||
->searchable()
|
||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||
]),
|
||||
])
|
||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeCreateReview(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
||||
$snapshot = is_numeric($snapshotId)
|
||||
? EvidenceSnapshot::query()
|
||||
->whereKey((int) $snapshotId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $review->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review already available')
|
||||
->body('A matching mutable review already exists for this evidence basis.')
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||
->body('The review is being composed in the background.');
|
||||
|
||||
if ($review->operation_run_id) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
|
||||
public static function executeExport(TenantReview $review): void
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($review->tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('export', $review)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRunForReview($review)) {
|
||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('An executive pack export is already queued or running for this review.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pack = $service->generateFromReview($review, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $pack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Executive pack already available')
|
||||
->body('A matching executive pack already exists for this review.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('The executive pack is being generated in the background.')
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public static function tenantScopedUrl(
|
||||
string $page = 'index',
|
||||
array $parameters = [],
|
||||
?Tenant $tenant = null,
|
||||
?string $panel = null,
|
||||
): string {
|
||||
$panelId = $panel ?? 'tenant';
|
||||
|
||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function evidenceSnapshotOptions(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereNotNull('generated_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
||||
(string) $snapshot->getKey() => sprintf(
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
Str::headline((string) $snapshot->completeness_state),
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function summaryPresentation(TenantReview $record): array
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
return [
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'metrics' => [
|
||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function sectionPresentation(TenantReviewSection $section): array
|
||||
{
|
||||
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$review = $section->tenantReview;
|
||||
$tenant = $section->tenant;
|
||||
|
||||
return [
|
||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||
if (is_array($value) || $value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => Str::headline($key),
|
||||
'value' => (string) $value,
|
||||
];
|
||||
})->filter()->values()->all(),
|
||||
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
||||
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||
'links' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantReviews extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantReviewResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
TenantReviewResource::makeCreateReviewAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewTenantReview extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantReviewResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = TenantReviewResource::panelTenantContext();
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('view', $record)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
||||
->url(fn (): ?string => $this->record->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
||||
: null),
|
||||
Actions\Action::make('view_export')
|
||||
->label('View executive pack')
|
||||
->icon('heroicon-o-document-arrow-down')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
||||
: null),
|
||||
Actions\Action::make('view_evidence')
|
||||
->label('View evidence snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
||||
: null),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_review')
|
||||
->label('Refresh review')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->success()->title('Refresh review queued')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('publish_review')
|
||||
->label('Publish review')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
||||
Notification::make()->success()->title('Review published')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_next_review')
|
||||
->label('Create next review')
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive_review')
|
||||
->label('Archive review')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
||||
$this->refreshFormData(['status', 'archived_at']);
|
||||
|
||||
Notification::make()->success()->title('Review archived')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -107,10 +107,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->form($this->findingsScopeForm())
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||
'mode' => $data['scope_mode'] ?? null,
|
||||
'tenant_id' => $data['tenant_id'] ?? null,
|
||||
]);
|
||||
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
||||
|
||||
$this->findingsScopeMode = $scope->mode;
|
||||
$this->findingsTenantId = $scope->tenantId;
|
||||
@ -142,9 +139,7 @@ protected function getHeaderActions(): array
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
|
||||
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
|
||||
: FindingsLifecycleBackfillScope::allTenants();
|
||||
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
||||
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -286,4 +281,34 @@ private function lastRunForType(string $type): ?OperationRun
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||
'mode' => $data['scope_mode'] ?? null,
|
||||
'tenant_id' => $data['tenant_id'] ?? null,
|
||||
]);
|
||||
|
||||
if (! $scope->isSingleTenant()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
|
||||
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
|
||||
return FindingsLifecycleBackfillScope::allTenants();
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
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;
|
||||
@ -42,16 +41,7 @@ public function table(Table $table): Table
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->limit(40)
|
||||
->formatStateUsing(function (?string $state, Finding $record): ?string {
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
})
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(function (Finding $record): ?string {
|
||||
if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') {
|
||||
return null;
|
||||
@ -59,17 +49,7 @@ public function table(Table $table): Table
|
||||
|
||||
return __('findings.drift.rbac_role_definition');
|
||||
})
|
||||
->tooltip(function (Finding $record): ?string {
|
||||
$displayName = $record->subject_display_name;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
|
||||
return is_string($fallback) && trim($fallback) !== '' ? trim($fallback) : null;
|
||||
}),
|
||||
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->sortable()
|
||||
@ -106,13 +86,7 @@ private function getQuery(): Builder
|
||||
$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),
|
||||
])
|
||||
->withSubjectDisplayName()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('created_at');
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -14,7 +15,6 @@
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@ -22,6 +22,8 @@
|
||||
|
||||
class InventoryKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
@ -36,15 +38,15 @@ class InventoryKpiHeader extends StatsOverviewWidget
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
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('Coverage', '0%')->description('Select a tenant to load coverage.'),
|
||||
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
|
||||
Stat::make('Active ops', 0),
|
||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
||||
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
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;
|
||||
@ -23,7 +23,7 @@ class OperationsKpiHeader extends StatsOverviewWidget
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->activeTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -37,7 +37,7 @@ protected function getPollingInterval(): ?string
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->activeTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
@ -110,6 +110,13 @@ protected function getStats(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function activeTenant(): ?Tenant
|
||||
{
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
private static function formatDurationSeconds(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
@ -23,6 +24,7 @@ protected function getViewData(): array
|
||||
|
||||
return [
|
||||
'tenant' => $tenant instanceof Tenant ? $tenant : null,
|
||||
'presentation' => $tenant instanceof Tenant ? TenantLifecyclePresentation::fromTenant($tenant) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,6 +131,7 @@ protected function getViewData(): array
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->with('tenantReview')
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
@ -146,6 +147,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -158,6 +160,11 @@ protected function getViewData(): array
|
||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||
}
|
||||
|
||||
$reviewUrl = null;
|
||||
if ($latestPack->tenantReview && $canView) {
|
||||
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||
@ -173,6 +180,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'reviewUrl' => $reviewUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -200,6 +208,7 @@ private function emptyState(): array
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
@ -189,9 +190,15 @@ protected function getViewData(): array
|
||||
|
||||
$user = auth()->user();
|
||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||
$canOperate = app(TenantOperabilityService::class)->decisionFor($tenant)->canOperate;
|
||||
$canStart = $isTenantMember
|
||||
&& $canOperate
|
||||
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
$lifecycleNotice = $isTenantMember && ! $canOperate
|
||||
? 'Verification can be started from tenant management only while the tenant is active.'
|
||||
: null;
|
||||
|
||||
$runData = null;
|
||||
|
||||
if ($run instanceof OperationRun) {
|
||||
@ -220,8 +227,10 @@ protected function getViewData(): array
|
||||
'report' => $report,
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
'isInProgress' => $isInProgress,
|
||||
'showStartAction' => $isTenantMember && $canOperate,
|
||||
'canStart' => $canStart,
|
||||
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null,
|
||||
'startTooltip' => $isTenantMember && $canOperate && ! $canStart ? UiTooltips::insufficientPermission() : null,
|
||||
'lifecycleNotice' => $lifecycleNotice,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,11 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
||||
@ -51,25 +55,47 @@ public function __invoke(
|
||||
error: $error,
|
||||
);
|
||||
|
||||
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
||||
$auditMetadata = [
|
||||
'source' => 'admin.consent.callback',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'status' => $status,
|
||||
'state' => $state,
|
||||
'error' => $error,
|
||||
'consent' => $consentGranted,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'consent_status' => $connection->consent_status->value,
|
||||
'verification_status' => $connection->verification_status->value,
|
||||
];
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.consent.callback',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'status' => $status,
|
||||
'state' => $state,
|
||||
'error' => $error,
|
||||
'consent' => $consentGranted,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'metadata' => $auditMetadata,
|
||||
],
|
||||
status: $status === 'ok' ? 'success' : 'error',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: $legacyStatus,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.consent_result',
|
||||
context: [
|
||||
'metadata' => $auditMetadata,
|
||||
],
|
||||
status: $legacyStatus,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
);
|
||||
|
||||
return view('admin-consent-callback', [
|
||||
'tenant' => $tenant,
|
||||
'connection' => $connection,
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
'consentGranted' => $consentGranted,
|
||||
@ -112,16 +138,19 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
->where('is_default', true)
|
||||
->exists();
|
||||
|
||||
$connectionStatus = match ($status) {
|
||||
'ok' => 'connected',
|
||||
'error' => 'error',
|
||||
'consent_denied' => 'needs_consent',
|
||||
default => 'needs_consent',
|
||||
$consentStatus = match ($status) {
|
||||
'ok' => ProviderConsentStatus::Granted,
|
||||
'error' => ProviderConsentStatus::Failed,
|
||||
default => ProviderConsentStatus::Required,
|
||||
};
|
||||
|
||||
$verificationStatus = ProviderVerificationStatus::Unknown;
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
$reasonCode = match ($status) {
|
||||
'ok' => null,
|
||||
'consent_denied' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'error' => ProviderReasonCodes::ProviderAuthFailed,
|
||||
default => ProviderReasonCodes::ProviderConsentMissing,
|
||||
};
|
||||
@ -135,19 +164,30 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'status' => $connectionStatus,
|
||||
'health_status' => $connectionStatus === 'connected' ? 'unknown' : 'degraded',
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => $consentStatus->value,
|
||||
'consent_granted_at' => $status === 'ok' ? now() : null,
|
||||
'consent_last_checked_at' => now(),
|
||||
'consent_error_code' => $reasonCode,
|
||||
'consent_error_message' => $error,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => $reasonCode,
|
||||
'last_error_message' => $error,
|
||||
'is_default' => $hasDefault ? false : true,
|
||||
],
|
||||
);
|
||||
|
||||
$connection->credential()->delete();
|
||||
|
||||
if (! $hasDefault && ! $connection->is_default) {
|
||||
$connection->makeDefault();
|
||||
}
|
||||
|
||||
return $connection;
|
||||
return $connection->fresh();
|
||||
}
|
||||
|
||||
private function parseState(?string $state): ?string
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -15,14 +16,31 @@ public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
$workspaceContext->clearRememberedTenantContext($request);
|
||||
|
||||
$previousUrl = url()->previous();
|
||||
|
||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
||||
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
|
||||
|
||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
||||
return redirect()->to('/admin/operations');
|
||||
return redirect()->route('admin.operations.index');
|
||||
}
|
||||
|
||||
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
|
||||
$workspace = $workspaceContext->currentWorkspace($request);
|
||||
|
||||
if ($workspace !== null) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.home');
|
||||
}
|
||||
|
||||
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
|
||||
return redirect()->route('admin.operations.index');
|
||||
}
|
||||
|
||||
return redirect()->to((string) $previousUrl);
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -34,7 +37,6 @@ public function __invoke(Request $request): RedirectResponse
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($validated['tenant_id'])
|
||||
->first();
|
||||
@ -47,9 +49,23 @@ public function __invoke(Request $request): RedirectResponse
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
);
|
||||
|
||||
if (! $outcome->allowed) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, $request)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@ -47,6 +48,8 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$prevWorkspaceId = $context->currentWorkspaceId($request);
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
$context->rememberedTenant($request);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
/** @var WorkspaceAuditLogger $auditLogger */
|
||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||
|
||||
@ -2,21 +2,30 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
||||
|
||||
class TenantOnboardingController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$clientId = config('graph.client_id');
|
||||
$redirectUri = route('admin.consent.callback');
|
||||
$targetTenant = $request->string('tenant')->toString() ?: config('graph.tenant_id', 'organizations');
|
||||
$tenantSegment = $targetTenant ?: 'organizations';
|
||||
|
||||
abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured');
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
AdminConsentUrlFactory $consentUrlFactory,
|
||||
AuditLogger $auditLogger,
|
||||
): RedirectResponse {
|
||||
$tenantIdentifier = $request->string('tenant')->toString();
|
||||
abort_if($tenantIdentifier === '', ResponseAlias::HTTP_NOT_FOUND);
|
||||
|
||||
$state = Str::uuid()->toString();
|
||||
$request->session()->put('tenant_onboard_state', $state);
|
||||
@ -27,13 +36,108 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$request->session()->put('tenant_onboard_workspace_id', (int) $workspaceId);
|
||||
}
|
||||
|
||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'state' => $state,
|
||||
]);
|
||||
$tenant = $this->resolveTenant($tenantIdentifier, is_numeric($workspaceId) ? (int) $workspaceId : null);
|
||||
$connection = $this->upsertPlatformConnection($tenant);
|
||||
$url = $consentUrlFactory->make($connection, $state);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.consent_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'admin.consent.start',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'effective_client_id' => trim((string) config('graph.client_id')),
|
||||
'state' => $state,
|
||||
],
|
||||
],
|
||||
actorId: auth()->id(),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $connection->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
private function resolveTenant(string $tenantIdentifier, ?int $workspaceId): Tenant
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->where(function ($query) use ($tenantIdentifier): void {
|
||||
$query->where('tenant_id', $tenantIdentifier)
|
||||
->orWhere('external_id', $tenantIdentifier);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
if ($tenant->workspace_id === null && $workspaceId !== null) {
|
||||
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
abort_if($workspaceId === null, ResponseAlias::HTTP_FORBIDDEN, 'Missing workspace context');
|
||||
|
||||
return Tenant::create([
|
||||
'tenant_id' => $tenantIdentifier,
|
||||
'name' => 'New Tenant',
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
|
||||
{
|
||||
$hasDefault = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->exists();
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) ($tenant->graphTenantId() ?? $tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'is_default' => $hasDefault ? false : true,
|
||||
],
|
||||
);
|
||||
|
||||
$connection->credential()->delete();
|
||||
|
||||
if (! $hasDefault && ! $connection->is_default) {
|
||||
$connection->makeDefault();
|
||||
}
|
||||
|
||||
return $connection->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,18 +173,31 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
if (preg_match('#^/admin/onboarding/[^/]+$#', $path) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->isLivewireUpdatePath($path)) {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isLivewireUpdatePath(string $path): bool
|
||||
{
|
||||
return preg_match('#^/livewire(?:-[^/]+)?/update$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isChooserFirstPath(string $path): bool
|
||||
{
|
||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
@ -32,6 +33,11 @@ public function __construct(
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new EnsureQueuedExecutionLegitimate];
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
@ -32,6 +33,11 @@ public function __construct(
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new EnsureQueuedExecutionLegitimate];
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
use App\Services\OperationRunService;
|
||||
@ -1674,9 +1675,8 @@ private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersio
|
||||
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
||||
{
|
||||
if ($version instanceof PolicyVersion) {
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
|
||||
is_array($version->snapshot) ? $version->snapshot : [],
|
||||
'intuneRoleDefinition',
|
||||
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
);
|
||||
}
|
||||
@ -2131,20 +2131,14 @@ private function upsertFindings(
|
||||
: null;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
|
||||
$severity = (string) $driftItem['severity'];
|
||||
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
$finding->save();
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => $observedAt,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => null,
|
||||
'closed_reason' => null,
|
||||
'closed_by_user_id' => null,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
app(FindingWorkflowService::class)->reopenBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: (int) $this->operationRun->getKey(),
|
||||
);
|
||||
|
||||
$reopenedCount++;
|
||||
} else {
|
||||
|
||||
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class ComposeTenantReviewJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantReviewId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(TenantReviewService $service, OperationRunService $operationRuns): void
|
||||
{
|
||||
$review = TenantReview::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->tenantReviewId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $review instanceof TenantReview || ! $operationRun instanceof OperationRun || ! $review->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
||||
$review->update(['status' => TenantReviewStatus::Draft->value]);
|
||||
|
||||
try {
|
||||
$review = $service->compose($review);
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$review->update([
|
||||
'status' => TenantReviewStatus::Failed->value,
|
||||
'summary' => array_merge(is_array($review->summary) ? $review->summary : [], [
|
||||
'error' => $throwable->getMessage(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'tenant_review_compose.failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Listeners\SyncRestoreRunToOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
@ -34,6 +36,14 @@ public function __construct(
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
||||
{
|
||||
if (! $this->operationRun) {
|
||||
|
||||
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class GenerateEvidenceSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $snapshotId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(EvidenceSnapshotService $service, OperationRunService $operationRuns): void
|
||||
{
|
||||
$snapshot = EvidenceSnapshot::query()->with('tenant')->find($this->snapshotId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot || ! $operationRun instanceof OperationRun || ! $snapshot->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
||||
$snapshot->update(['status' => EvidenceSnapshotStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
||||
$previousActive = EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
||||
->whereKeyNot((int) $snapshot->getKey())
|
||||
->first();
|
||||
|
||||
$snapshot->items()->delete();
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'workspace_id' => (int) $snapshot->workspace_id,
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
||||
$previousActive->update([
|
||||
'status' => EvidenceSnapshotStatus::Superseded->value,
|
||||
]);
|
||||
}
|
||||
|
||||
$snapshot->update([
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'previous_fingerprint' => $previousActive?->fingerprint,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'generated_at' => now(),
|
||||
'summary' => $payload['summary'],
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$snapshot->update([
|
||||
'status' => EvidenceSnapshotStatus::Failed->value,
|
||||
'summary' => [
|
||||
'error' => $throwable->getMessage(),
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'evidence_snapshot_generation.failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,12 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
@ -34,7 +35,7 @@ public function __construct(
|
||||
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
@ -54,12 +55,20 @@ public function handle(OperationRunService $operationRunService): void
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $reviewPack->evidenceSnapshot;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running via OperationRunService (auto-sets started_at)
|
||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||
|
||||
@ -67,60 +76,44 @@ public function handle(OperationRunService $operationRunService): void
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
||||
{
|
||||
$review = $reviewPack->tenantReview;
|
||||
|
||||
if ($review instanceof TenantReview) {
|
||||
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$findingsPayload = $this->itemSummaryPayload($items->get('findings_summary'));
|
||||
$permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture'));
|
||||
$entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles'));
|
||||
$operationsPayload = $this->itemSummaryPayload($items->get('operations_summary'));
|
||||
$riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null)
|
||||
? $snapshot->summary['risk_acceptance']
|
||||
: (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []);
|
||||
|
||||
// 1. Collect StoredReports
|
||||
$storedReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->get()
|
||||
->keyBy('report_type');
|
||||
|
||||
// 2. Collect open findings
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderBy('severity')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// 3. Collect tenant hardening fields
|
||||
$hardening = [
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||
];
|
||||
|
||||
// 4. Collect recent OperationRuns (30 days)
|
||||
$recentOperations = $includeOperations
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 5. Data freshness
|
||||
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
||||
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
||||
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
||||
$dataFreshness = $this->computeDataFreshness($items);
|
||||
|
||||
// 6. Build file map
|
||||
$fileMap = $this->buildFileMap(
|
||||
storedReports: $storedReports,
|
||||
findings: $findings,
|
||||
hardening: $hardening,
|
||||
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
||||
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
||||
recentOperations: $recentOperations,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
dataFreshness: $dataFreshness,
|
||||
riskAcceptance: $riskAcceptance,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
@ -154,16 +147,24 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'data_freshness' => $dataFreshness,
|
||||
'risk_acceptance' => $riskAcceptance,
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
],
|
||||
];
|
||||
|
||||
// 12. Update ReviewPack
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
@ -183,18 +184,113 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
);
|
||||
}
|
||||
|
||||
private function executeReviewDerivedGeneration(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
OperationRun $operationRun,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
OperationRunService $operationRunService,
|
||||
): void {
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
|
||||
$fileMap = $this->buildReviewDerivedFileMap(
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
$fileSize = filesize($tempFile);
|
||||
$filePath = sprintf(
|
||||
'review-packs/%s/review-%d-%s.zip',
|
||||
$tenant->external_id,
|
||||
(int) $review->getKey(),
|
||||
now()->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
$summary = [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
],
|
||||
];
|
||||
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
$review->update([
|
||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'summary' => array_merge($reviewSummary, [
|
||||
'has_ready_export' => true,
|
||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
* @return array<string, ?string>
|
||||
*/
|
||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||
private function computeDataFreshness($items): array
|
||||
{
|
||||
return [
|
||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
||||
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
||||
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -204,12 +300,15 @@ private function computeDataFreshness($storedReports, $findings, Tenant $tenant)
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildFileMap(
|
||||
$storedReports,
|
||||
$findings,
|
||||
array $hardening,
|
||||
array $permissionPosture,
|
||||
array $entraAdminRoles,
|
||||
$recentOperations,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $dataFreshness,
|
||||
array $riskAcceptance,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
@ -227,6 +326,12 @@ private function buildFileMap(
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'evidence_snapshot' => [
|
||||
'id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'redaction_integrity' => [
|
||||
'protected_values_hidden' => true,
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
@ -241,16 +346,14 @@ private function buildFileMap(
|
||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||
|
||||
// reports/entra_admin_roles.json
|
||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($entraAdminRoles, $includePii),
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// reports/permission_posture.json
|
||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||
$files['reports/permission_posture.json'] = json_encode(
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($permissionPosture, $includePii),
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
@ -258,8 +361,10 @@ private function buildFileMap(
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'risk_acceptance' => $riskAcceptance,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
@ -273,18 +378,33 @@ private function buildFileMap(
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
fputcsv($handle, [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
$row = $finding instanceof Finding
|
||||
? [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
]
|
||||
: [
|
||||
$finding['id'] ?? '',
|
||||
$finding['finding_type'] ?? '',
|
||||
$finding['severity'] ?? '',
|
||||
$finding['status'] ?? '',
|
||||
$includePii ? ($finding['title'] ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding['description'] ?? '') : '[REDACTED]',
|
||||
$finding['created_at'] ?? '',
|
||||
$finding['updated_at'] ?? '',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
...$row,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -301,17 +421,31 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
$row = $operation instanceof OperationRun
|
||||
? [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
]
|
||||
: [
|
||||
$operation['id'] ?? '',
|
||||
$operation['type'] ?? '',
|
||||
$operation['status'] ?? '',
|
||||
$operation['outcome'] ?? '',
|
||||
$includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]',
|
||||
$operation['started_at'] ?? '',
|
||||
$operation['completed_at'] ?? '',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
...$row,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -322,6 +456,15 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $handle
|
||||
* @param array<int, mixed> $row
|
||||
*/
|
||||
private function writeCsvRow($handle, array $row): void
|
||||
{
|
||||
fputcsv($handle, $row, ',', '"', '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from a report payload.
|
||||
*
|
||||
@ -431,9 +574,98 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildReviewDerivedFileMap(
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
|
||||
$sections = $review->sections
|
||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
||||
->values();
|
||||
|
||||
$files = [
|
||||
'metadata.json' => json_encode([
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'tenant_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
'completeness_state' => (string) $review->completeness_state,
|
||||
'published_at' => $review->published_at?->toIso8601String(),
|
||||
'fingerprint' => (string) $review->fingerprint,
|
||||
],
|
||||
'evidence_snapshot' => [
|
||||
'id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
],
|
||||
'redaction_integrity' => [
|
||||
'protected_values_hidden' => true,
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
|
||||
return [
|
||||
'section_key' => (string) $section->section_key,
|
||||
'title' => (string) $section->title,
|
||||
'sort_order' => (int) $section->sort_order,
|
||||
'required' => (bool) $section->required,
|
||||
'completeness_state' => (string) $section->completeness_state,
|
||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
];
|
||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
|
||||
|
||||
$files[$filename] = json_encode([
|
||||
'title' => (string) $section->title,
|
||||
'completeness_state' => (string) $section->completeness_state,
|
||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Failed->value,
|
||||
'summary' => array_merge($reviewPack->summary ?? [], [
|
||||
'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [
|
||||
'outcome' => $reasonCode,
|
||||
'reasons' => [mb_substr($errorMessage, 0, 500)],
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
@ -444,4 +676,13 @@ private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function itemSummaryPayload(mixed $item): array
|
||||
{
|
||||
if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $item->summary_payload;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user