Compare commits
4 Commits
193-monito
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| acc8947384 | |||
| efd4f31ba3 | |||
| 68be99e27b | |||
| bef9020159 |
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -173,6 +173,7 @@ ## Active Technologies
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`) (194-governance-friction-hardening)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -207,8 +208,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 194-governance-friction-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`)
|
||||
- 193-monitoring-action-hierarchy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders
|
||||
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,24 +1,37 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.0.0 -> 2.1.0
|
||||
- Version change: 2.2.0 -> 2.3.0
|
||||
- Modified principles:
|
||||
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||
with cross-reference to new HDR-001
|
||||
- UI-CONST-001: expanded to make TenantPilot's decision-first
|
||||
governance identity explicit
|
||||
- UI-REVIEW-001: spec and PR review gates expanded for surface role,
|
||||
human-in-the-loop justification, workflow-vs-storage IA, and
|
||||
attention-load reduction
|
||||
- Immediate Retrofit Priorities: expanded with a classification-first
|
||||
wave for existing surfaces
|
||||
- Added sections:
|
||||
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
- Decision-First Operating Model & Progressive Disclosure
|
||||
(DECIDE-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
||||
UI/UX Surface Classification and Operator Surface Contract tables already
|
||||
cover header action placement implicitly)
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check updated for
|
||||
decision-first surface roles, workflow-first IA, and calm-surface
|
||||
review)
|
||||
- ✅ .specify/templates/spec-template.md (surface role classification,
|
||||
operator contract, and requirements updated for decision-first
|
||||
governance)
|
||||
- ✅ .specify/templates/tasks-template.md (implementation task guidance
|
||||
updated for progressive disclosure, single-case context, and
|
||||
attention-load reduction)
|
||||
- ✅ docs/product/standards/README.md (Constitution index updated for
|
||||
DECIDE-001)
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
- Create a dedicated surface / IA classification spec to retrofit
|
||||
existing surfaces against DECIDE-001.
|
||||
-->
|
||||
|
||||
# TenantPilot Constitution
|
||||
@ -318,13 +331,189 @@ ### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||
|
||||
Purpose and scope
|
||||
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
||||
- It defines allowed surface types, allowed interaction models, primary/secondary/destructive action hierarchy, list/detail/queue semantics, scope and context signals, canonical navigation and naming rules, visibility of critical operational truth, scanability and density rules, exception handling, and review and enforcement requirements.
|
||||
- It defines decision-first prominence roles, allowed surface types,
|
||||
allowed interaction models, primary/secondary/destructive action
|
||||
hierarchy, list/detail/queue semantics, scope and context signals,
|
||||
canonical navigation and naming rules, visibility of critical
|
||||
operational truth, scanability and density rules, exception handling,
|
||||
and review and enforcement requirements.
|
||||
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
||||
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
||||
|
||||
#### Decision-First Operating Model & Progressive Disclosure (DECIDE-001)
|
||||
|
||||
Goal: TenantPilot is primarily a governance and decision platform, not
|
||||
a browser for internal technical detail objects. This section governs
|
||||
surface prominence and default information depth. It is orthogonal to
|
||||
UI-SURF-001 and ACTSURF-001: every operator-facing surface MUST declare
|
||||
both its interaction model and its decision-role prominence.
|
||||
|
||||
##### Surface prominence roles
|
||||
|
||||
- Every operator-facing surface MUST declare exactly one decision-role
|
||||
prominence:
|
||||
- Primary Decision Surface
|
||||
- Secondary Context Surface
|
||||
- Tertiary Evidence / Diagnostics Surface
|
||||
- Decision-role prominence is separate from action-surface class and
|
||||
detailed surface type.
|
||||
- Prominence determines what deserves top-level navigation, default
|
||||
emphasis, and default-visible information depth.
|
||||
|
||||
##### Primary surfaces are for human decisions
|
||||
|
||||
- Primary Decision Surfaces MUST support a clear human-in-the-loop
|
||||
moment such as attention prioritization, approval, risk acceptance or
|
||||
rejection, drift / findings / exception triage, review completion,
|
||||
evaluation of blocked or failed automations, or execution /
|
||||
escalation of the next governance action.
|
||||
- A prominent surface MUST NOT exist primarily to display internal
|
||||
model objects, raw data, diagnostics, or technical object hubs
|
||||
without clear operator value.
|
||||
- Every proposed primary surface MUST answer: what concrete decision or
|
||||
operator action does this surface support?
|
||||
|
||||
##### Detail surfaces are evidence surfaces
|
||||
|
||||
- OperationRun detail, evidence detail, policy version detail, audit
|
||||
log detail, JSON / payload / diff views, and deep diagnostic contexts
|
||||
are normally Secondary Context or Tertiary Evidence / Diagnostics
|
||||
surfaces.
|
||||
- These surfaces remain essential for verification, diagnosis, and
|
||||
auditability, but they MUST NOT dominate default operator workflows
|
||||
or primary navigation merely because the underlying objects exist.
|
||||
|
||||
##### Default to decisions, not details
|
||||
|
||||
- Default-visible information MUST first answer what happened, why it
|
||||
matters, how urgent it is, what the system recommends, what impact
|
||||
the decision has, and what action or approval is required now.
|
||||
- Internal IDs, relation depth, raw payloads, full snapshot history,
|
||||
debug views, and unstructured technical detail MUST stay secondary
|
||||
unless they are required for the first decision.
|
||||
|
||||
##### Progressive disclosure is the default
|
||||
|
||||
- Depth MUST be preserved but revealed on demand through
|
||||
expand/collapse, drawers, tabs, side panels, explicit "Show details"
|
||||
affordances, or focused drill-downs from a clear decision context.
|
||||
- The default workflow SHOULD let the operator decide before navigating
|
||||
through diagnostic depth.
|
||||
- Primary flows MUST NOT force operators through multiple technical
|
||||
subpages before a single governance decision can be made.
|
||||
|
||||
##### Navigation follows workflows, not storage structures
|
||||
|
||||
- Primary navigation and prominent entry points MUST follow operator
|
||||
workflows such as pending decisions, alerts / escalations, reviews,
|
||||
exceptions / accepted risks, governance priorities, and blocked or
|
||||
failed automations.
|
||||
- Internal persistence terms such as OperationRuns, EvidenceItems,
|
||||
PolicyVersions, StoredReports, or relational chains MAY exist as
|
||||
supporting surfaces, but they do not earn primary information
|
||||
architecture status by default.
|
||||
- Every navigation proposal MUST answer: does this reflect a working
|
||||
task or only an internal storage structure?
|
||||
|
||||
##### Meaning comes before model names
|
||||
|
||||
- Operator-facing surfaces MUST prefer governance language such as
|
||||
"Drift detected", "Exception expires soon", "Evidence incomplete",
|
||||
"Review ready", "Remediation recommended", or "Further review
|
||||
required".
|
||||
- Model names, table/entity language, relation terminology, and
|
||||
implementation-first state labels MUST NOT be the primary UX
|
||||
language when business meaning can be expressed directly.
|
||||
|
||||
##### One case, one decision context
|
||||
|
||||
- A single governance case SHOULD be decidable within one focused
|
||||
context that brings together the problem, risk or relevance,
|
||||
recommendation, impact, ownership, next action, approval options, and
|
||||
optional detail beneath or beside the decision.
|
||||
- Operators MUST NOT be forced to reconstruct one decision across
|
||||
multiple equal-rank Run, Evidence, Policy, Audit, and Finding pages
|
||||
when the product can present one coherent decision context.
|
||||
|
||||
##### Audit depth is mandatory; dominance is not
|
||||
|
||||
- Enterprise-grade evidence, verification, and audit depth MUST remain
|
||||
available.
|
||||
- Audit requirements do NOT justify default surfaces that look or
|
||||
behave like forensic diagnostics consoles.
|
||||
- The standard operator flow SHOULD remain calm, prioritized, and
|
||||
decision-led even when deep proof is available.
|
||||
|
||||
##### New primary surfaces require strict justification
|
||||
|
||||
- Every new top-level or otherwise prominent surface MUST justify:
|
||||
1. which human-in-the-loop moment it supports,
|
||||
2. why an existing surface is insufficient,
|
||||
3. why a drawer, panel, tab, or embedded decision context is
|
||||
insufficient,
|
||||
4. what search, review, or click work it removes.
|
||||
- If those answers are weak, the work MUST reuse an existing decision
|
||||
context or remain secondary/tertiary.
|
||||
|
||||
##### Automation must reduce attention load
|
||||
|
||||
- New automation, notification, or autonomous governance behavior MUST
|
||||
measurably reduce search work, review work, or click load.
|
||||
- Automation that primarily creates extra lists, statuses, surfaces, or
|
||||
detail work is non-conformant even if technically correct.
|
||||
- The review question is: does this make the platform quieter and
|
||||
clearer, or merely larger?
|
||||
|
||||
##### Calm default surfaces
|
||||
|
||||
- The default workspace experience MUST distinguish clearly between
|
||||
immediately actionable work, worth-watching context, and
|
||||
reference-only information.
|
||||
- Unranked warning floods, parallel attention entry points, and
|
||||
perpetual visual escalation are forbidden on primary surfaces.
|
||||
- A surface that only creates noise instead of priority is
|
||||
non-conformant.
|
||||
|
||||
##### Retrofit requirement
|
||||
|
||||
- DECIDE-001 applies to existing as well as new surfaces.
|
||||
- Existing surfaces MUST be reclassified as Primary Decision,
|
||||
Secondary Context, or Tertiary Evidence / Diagnostics surfaces and
|
||||
then reviewed for prominence, disclosure, consolidation, and
|
||||
workflow alignment.
|
||||
- Surface retrofit work SHOULD prefer reclassification and
|
||||
consolidation before creating new navigation branches.
|
||||
|
||||
##### Review gate
|
||||
|
||||
Every operator-facing spec or PR that changes a surface MUST answer:
|
||||
1. What concrete decision or operator action does this support?
|
||||
2. Who is the human in the loop?
|
||||
3. What MUST be immediately visible for the first decision?
|
||||
4. What is preserved but only revealed on demand?
|
||||
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||
Tertiary Evidence / Diagnostics Surface?
|
||||
6. If it is primary, why can it not live inside an existing decision
|
||||
context?
|
||||
7. Does the navigation reflect a workflow or only storage structure?
|
||||
8. Does this reduce search, review, or click work?
|
||||
9. Does this make the product calmer and clearer instead of louder?
|
||||
|
||||
#### Surface Taxonomy (UI-SURF-001)
|
||||
|
||||
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||
class before implementation. Ad-hoc interaction models are forbidden.
|
||||
|
||||
The allowed broad action-surface classes are:
|
||||
- Record / Detail / Edit
|
||||
- Monitoring / Queue / Workbench
|
||||
- List / Table / Bulk
|
||||
- Wizard / Flow
|
||||
- Utility / System
|
||||
|
||||
Operator-facing surfaces MUST also declare exactly one detailed surface
|
||||
type from the taxonomy below. The broad class determines the action
|
||||
hierarchy first; the detailed surface type refines it.
|
||||
|
||||
##### CRUD / List-first Resource
|
||||
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||
@ -377,6 +566,157 @@ ##### Detail-first Operational Surface
|
||||
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||
- Row click and explicit View/Inspect: not applicable.
|
||||
|
||||
#### Action Surface Discipline (ACTSURF-001)
|
||||
|
||||
Goal: actions across all surfaces MUST make the next sensible operator
|
||||
step obvious, keep safe navigation distinct from mutation, and prevent
|
||||
dangerous or governance-relevant actions from sitting casually beside
|
||||
harmless context changes.
|
||||
|
||||
##### Surface class first
|
||||
|
||||
- Every new or materially changed surface MUST declare exactly one broad
|
||||
action-surface class before actions are designed.
|
||||
- Different surface classes MAY use different action models only when
|
||||
the difference is deliberate, documented, and justified by the
|
||||
workflow.
|
||||
- Detailed surface types refine the rule set; they do not replace the
|
||||
broad class requirement.
|
||||
|
||||
##### Record / Detail / Edit surfaces
|
||||
|
||||
- Classic record/detail/edit pages MUST expose at most one visible
|
||||
primary header action.
|
||||
- Pure navigation MUST NOT live in the header when it can be placed
|
||||
inline at summary, field, badge, status, or related-context level.
|
||||
- Secondary, rare, or administrative actions MUST be grouped.
|
||||
- Multiple equally weighted mutation buttons in the header are
|
||||
forbidden.
|
||||
- Destructive, irreversible, or governance-relevant actions MUST be
|
||||
clearly separated from routine actions.
|
||||
- The likely next operator step MUST be recognizable within seconds.
|
||||
- HDR-001 is the binding specialization for record/detail/edit headers.
|
||||
|
||||
##### Monitoring / Queue / Workbench surfaces
|
||||
|
||||
- Surface-level context, scope context, navigation, selection actions,
|
||||
and object actions MUST NOT be mixed as one flat header strip.
|
||||
- Scope indicators are context signals, not ordinary calls to action.
|
||||
- Selection-dependent actions SHOULD become prominent only when a
|
||||
selection or focused object actually exists.
|
||||
- Record-page header rules MUST NOT be copied blindly onto workbench
|
||||
surfaces.
|
||||
- Workbench surfaces MAY use a different action model, but that model
|
||||
MUST be explicit, repeatable, and internally consistent.
|
||||
|
||||
##### List / Table / Bulk surfaces
|
||||
|
||||
- Inspect/open affordances MUST remain consistent within the same
|
||||
surface class.
|
||||
- Bulk actions are allowed only for genuine multi-record work.
|
||||
- Row actions MUST NOT dominate reading and scanning.
|
||||
- Rare, destructive, or governance-relevant actions MUST NOT accumulate
|
||||
casually in default row actions.
|
||||
- Tables exist primarily to scan, filter, compare, and decide; they
|
||||
MUST NOT become unstructured action stockpiles.
|
||||
|
||||
##### Wizard / Flow surfaces
|
||||
|
||||
- Wizard actions MUST reflect staged progression, explicit back/cancel
|
||||
semantics, and safe confirmation at the step where risk becomes real.
|
||||
- Wizard pages MAY expose more than one visible action when the flow
|
||||
genuinely requires progression, backtracking, or guarded cancellation.
|
||||
- Even in a wizard, the next primary step MUST remain obvious.
|
||||
|
||||
##### Utility / System surfaces
|
||||
|
||||
- Utility and system pages MAY use narrower tooling-oriented action
|
||||
sets, but they MUST still separate safe navigation, routine control,
|
||||
and dangerous intervention.
|
||||
- System or recovery status does not justify casual placement of
|
||||
destructive or governance-changing actions.
|
||||
|
||||
##### Action grouping and order
|
||||
|
||||
- Actions MUST be ordered by meaning, frequency, and risk.
|
||||
- The preferred order is:
|
||||
1. primary next step
|
||||
2. common secondary action
|
||||
3. rare or contextual action
|
||||
4. dangerous or irreversible action
|
||||
- An `ActionGroup` / More menu is not a junk drawer. Navigation,
|
||||
mutation, external links, and destructive actions inside a group MUST
|
||||
still be named, ordered, and separated coherently.
|
||||
|
||||
##### Navigation vs mutation
|
||||
|
||||
- Navigation and mutation are different intent classes and MUST NOT
|
||||
appear as equal-weight peers without explicit hierarchy.
|
||||
- Harmless context switches MUST NOT visually overpower
|
||||
governance-relevant actions.
|
||||
- Pure context navigation SHOULD live near the content it concerns
|
||||
rather than as header filler.
|
||||
|
||||
##### Governance friction
|
||||
|
||||
- Actions with risk, blast radius, or irreversible effect MUST use
|
||||
shared governance-friction rules rather than per-surface improvisation.
|
||||
- Depending on impact, the required friction is confirmation, optional
|
||||
reason, mandatory reason, typed confirmation, or staged flow.
|
||||
- Clear danger semantics and separated placement are mandatory for
|
||||
dangerous or governance-changing actions.
|
||||
|
||||
##### Exceptions require explicit reason
|
||||
|
||||
- New surfaces MAY deviate only when the surface class or workflow truly
|
||||
requires it.
|
||||
- Allowed justification labels are:
|
||||
- Special type
|
||||
- Workflow hub
|
||||
- Wizard
|
||||
- Utility / System surface
|
||||
- Another clearly defined exception documented in the governing spec
|
||||
- "Historically grew this way" and "it was easy to add to the header"
|
||||
are invalid reasons.
|
||||
|
||||
##### Reuse before invention
|
||||
|
||||
- New features MUST reuse existing disciplined patterns, reference
|
||||
architectures, and shared primitives when they fit the chosen surface
|
||||
class.
|
||||
- Reference patterns are reuse baselines, not automatic mandates for
|
||||
every surface.
|
||||
|
||||
##### Constitution over convenience
|
||||
|
||||
- Local implementation speed MUST NOT override consistent action
|
||||
hierarchy.
|
||||
- No new feature may introduce:
|
||||
- multiple equal-rank header mutations without a clear primary
|
||||
- navigation as casual header filler
|
||||
- unreflective mixing of record, workbench, and governance patterns
|
||||
- new local exceptions without explicit rationale
|
||||
|
||||
##### Review gate
|
||||
|
||||
Every new or materially changed surface with actions MUST answer:
|
||||
1. What broad action-surface class is it?
|
||||
2. What is the one most likely next operator action?
|
||||
3. Is navigation cleanly separated from mutation?
|
||||
4. Are rare or risky actions removed from the primary plane?
|
||||
5. Is the hierarchy scanable in a few seconds?
|
||||
6. Is this a real special type or just an unordered exception?
|
||||
|
||||
If those answers are not clear, the surface is non-conformant.
|
||||
|
||||
##### Canonical outcome
|
||||
|
||||
- The goal is not the smallest possible number of buttons.
|
||||
- A conformant surface highlights the next sensible step, separates
|
||||
context, navigation, mutation, and danger cleanly, remains structured
|
||||
as capability grows, and applies the same principles consistently
|
||||
across the product.
|
||||
|
||||
#### Hard Rules (UI-HARD-001)
|
||||
|
||||
##### Primary inspect model
|
||||
@ -509,6 +849,8 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
|
||||
Behavior over declaration
|
||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||
- Every changed operator-facing surface MUST declare its broad
|
||||
action-surface class and the one most likely next operator action.
|
||||
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||
|
||||
@ -532,7 +874,10 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||
|
||||
Actions and flows
|
||||
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
||||
- Record / Detail / Edit pages MUST expose at most one visible primary
|
||||
header action. Any additional visible secondary header action requires
|
||||
explicit justification under ACTSURF-001 / HDR-001; the rest belong in
|
||||
groups or contextual placement.
|
||||
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||
- Destructive actions remain non-primary and confirmed.
|
||||
|
||||
@ -545,9 +890,12 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||
|
||||
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
#### Record / Detail / Edit Header Discipline & Contextual Navigation (HDR-001)
|
||||
|
||||
Goal: record, detail, and edit pages MUST be comprehensible within
|
||||
seconds. HDR-001 is the binding record/detail/edit specialization of
|
||||
ACTSURF-001.
|
||||
|
||||
Goal: record and detail pages MUST be comprehensible within seconds.
|
||||
Header actions are reserved for the primary workflow of the current page
|
||||
and MUST NOT become a dumping ground for every available action or
|
||||
navigation jump.
|
||||
@ -565,6 +913,8 @@ ##### Maximum one primary visible header action
|
||||
primary visible header action.
|
||||
- That action MUST represent the most obvious next operator step on
|
||||
exactly this page.
|
||||
- Multiple equally weighted mutation buttons in the header are
|
||||
forbidden.
|
||||
|
||||
##### Navigation does not belong in headers
|
||||
|
||||
@ -594,6 +944,8 @@ ##### Rare secondary actions belong in an Action Group
|
||||
or are only occasionally needed MUST NOT appear as equally weighted
|
||||
visible header buttons.
|
||||
- They MUST be placed in an Action Group.
|
||||
- The Action Group itself MUST remain structured; it MUST NOT become an
|
||||
unlabelled mix of navigation, external links, mutations, and danger.
|
||||
|
||||
##### Header clarity over implementation convenience
|
||||
|
||||
@ -744,17 +1096,58 @@ #### Spec Scope Fields (SCOPE-002)
|
||||
#### Enforcement Model (UI-REVIEW-001)
|
||||
|
||||
Spec review requirements
|
||||
- Every spec that changes an operator-facing surface MUST answer: surface type, primary inspect/open model, row-click rule, whether explicit View/Inspect exists or is forbidden, where secondary actions live, where destructive actions live, canonical collection route, canonical detail route, scope signals and their exact meaning, canonical noun, critical truth visible by default, and whether an exception type is used.
|
||||
- Every spec that changes an operator-facing surface MUST answer:
|
||||
decision-role prominence, human-in-the-loop moment, immediate-visible
|
||||
decision information, on-demand evidence/diagnostics boundary,
|
||||
whether a new primary surface is actually justified, broad
|
||||
action-surface class, detailed surface type, one likely next operator
|
||||
action, primary inspect/open model, row-click rule, whether explicit
|
||||
View/Inspect exists or is forbidden, where navigation lives, where
|
||||
secondary actions live, where destructive actions live, how grouped
|
||||
actions are ordered, canonical collection route, canonical detail
|
||||
route, scope signals and their exact meaning, canonical noun,
|
||||
critical truth visible by default, workflow-vs-storage IA
|
||||
justification, attention-load reduction, and whether an exception
|
||||
type is used.
|
||||
- Missing any of those answers makes the spec incomplete.
|
||||
|
||||
PR review requirements
|
||||
- A PR MUST NOT pass when it introduces more than one primary inspect model, redundant View beside row click, destructive inline actions beside inspect on standard lists, empty overflow or bulk groups, long workflow labels in dense rows, misleading scope chips, drifting domain nouns, hidden critical operational truth, or undocumented exceptions without dedicated tests.
|
||||
- A PR MUST NOT pass when it introduces more than one primary inspect
|
||||
model, redundant View beside row click, destructive inline actions
|
||||
beside inspect on standard lists, empty overflow or bulk groups, long
|
||||
workflow labels in dense rows, misleading scope chips, drifting domain
|
||||
nouns, hidden critical operational truth, flat record headers with
|
||||
multiple equal-weight mutations, workbench headers that mix scope,
|
||||
selection, navigation, and object actions as peers, a primary surface
|
||||
with no clear human-in-the-loop purpose, detail/evidence objects
|
||||
promoted into primary navigation without justification, one case
|
||||
fragmented across multiple equal-rank pages, new automation that adds
|
||||
attention surfaces without reducing operator work, noisy default
|
||||
surfaces with no action/watch/reference hierarchy, or undocumented
|
||||
exceptions without dedicated tests.
|
||||
|
||||
Guard tests
|
||||
- Repository guards SHOULD validate: declared surface type, conformant primary inspect model, absence of redundant View actions, presence of explicit Inspect on Queue / Review and History / Audit surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, correct placement of destructive actions, truthful scope signals, stable canonical nouns across shells, and dedicated tests for every approved exception.
|
||||
- Repository guards SHOULD validate: declared surface type, declared
|
||||
decision-role prominence where specs or metadata expose it,
|
||||
conformant primary inspect model, absence of redundant View actions,
|
||||
presence of explicit Inspect on Queue / Review and History / Audit
|
||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||
correct placement of destructive actions, truthful scope signals,
|
||||
stable canonical nouns across shells, and dedicated tests for every
|
||||
approved exception.
|
||||
|
||||
#### Immediate Retrofit Priorities
|
||||
|
||||
Wave 0 - Surface role classification
|
||||
- First classify existing surfaces as Primary Decision, Secondary
|
||||
Context, or Tertiary Evidence / Diagnostics surfaces.
|
||||
- For each surface, determine whether its current prominence is
|
||||
justified, which detail can move into progressive disclosure, and
|
||||
whether several technical pages should collapse into one decision
|
||||
context.
|
||||
- Wave 0 is done only when primary navigation candidates are grounded
|
||||
in workflows rather than storage structures.
|
||||
|
||||
Wave 1 - Interaction normalization
|
||||
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
||||
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||
@ -768,6 +1161,23 @@ #### Immediate Retrofit Priorities
|
||||
|
||||
#### Appendix A - One-page Condensed Constitution
|
||||
|
||||
- Every operator-facing surface declares one decision role:
|
||||
Primary Decision, Secondary Context, or Tertiary Evidence /
|
||||
Diagnostics.
|
||||
- Primary surfaces exist to help a human prioritize, judge, approve,
|
||||
reject, escalate, or act.
|
||||
- Evidence and diagnostics remain available but do not dominate the
|
||||
default workflow.
|
||||
- Default to decisions, not details.
|
||||
- Progressive disclosure preserves depth without forcing it into the
|
||||
first decision.
|
||||
- Navigation follows workflows, not storage structures.
|
||||
- One governance case should be decidable in one focused context.
|
||||
- Automation must reduce attention load.
|
||||
- Default surfaces stay calm, prioritized, and explicit about what is
|
||||
actionable, worth watching, and reference-only.
|
||||
- Every new or materially changed surface declares one broad
|
||||
action-surface class first.
|
||||
- Every admin surface has one surface type.
|
||||
- Every list has exactly one primary inspect/open model.
|
||||
- CRUD and Registry surfaces use one-click open.
|
||||
@ -777,6 +1187,10 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Destructive actions never sit openly beside inspect on standard lists.
|
||||
- Overflow is standardized per surface class and is never empty.
|
||||
- Bulk exists only when it is genuinely useful.
|
||||
- Navigation and mutation do not share equal visual weight without
|
||||
explicit hierarchy.
|
||||
- Monitoring and workbench surfaces separate scope/context, selection,
|
||||
navigation, and object actions.
|
||||
- Scope chips must be truthful.
|
||||
- Domain nouns are canonical and stable.
|
||||
- Critical operational truth is default-visible.
|
||||
@ -788,11 +1202,24 @@ #### Appendix A - One-page Condensed Constitution
|
||||
|
||||
#### Appendix B - Feature Review Checklist
|
||||
|
||||
- Surface type is declared.
|
||||
- Decision-role prominence is declared.
|
||||
- The human-in-the-loop moment is explicit.
|
||||
- Immediate-visible decision information is explicit.
|
||||
- On-demand evidence / diagnostics boundaries are explicit.
|
||||
- Any new primary surface is justified against an existing decision
|
||||
context.
|
||||
- Navigation reflects a workflow rather than storage structure.
|
||||
- One governance case stays decidable in one focused context.
|
||||
- The feature reduces search, review, or click work.
|
||||
- The resulting surface is calmer and clearer, not merely larger.
|
||||
- Broad action-surface class is declared.
|
||||
- Detailed surface type is declared.
|
||||
- The one most likely next operator action is explicit.
|
||||
- Primary inspect/open model is defined.
|
||||
- Row-click rule is decided.
|
||||
- View/Inspect is correctly present or correctly forbidden.
|
||||
- Edit-as-inspect is used only when allowed.
|
||||
- Navigation and mutation are separated intentionally.
|
||||
- Secondary actions are grouped correctly.
|
||||
- Destructive actions are placed correctly.
|
||||
- Overflow is not empty.
|
||||
@ -806,18 +1233,32 @@ #### Appendix B - Feature Review Checklist
|
||||
- Header passes the 5-second scan rule (HDR-001).
|
||||
- No pure navigation in the header.
|
||||
- Governance-changing actions have extra friction.
|
||||
- Any special type or workflow-hub exception is real and justified.
|
||||
|
||||
#### Appendix C - Red Flags for Future PRs
|
||||
|
||||
- A primary surface has no clear human-in-the-loop moment.
|
||||
- A technical object hub is promoted into primary navigation without
|
||||
workflow justification.
|
||||
- Default-visible content behaves like a diagnostics console instead of
|
||||
a decision surface.
|
||||
- The operator must assemble one decision from multiple equal-rank Run,
|
||||
Evidence, Policy, Audit, or Finding pages.
|
||||
- A feature adds automation, alerts, or statuses that increase net
|
||||
attention load.
|
||||
- The surface creates more noise than priority.
|
||||
- Row click and View open the same destination.
|
||||
- A row becomes a control center.
|
||||
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||
- More menus or bulk menus are empty.
|
||||
- A More menu becomes a mixed junk drawer with no ordering logic.
|
||||
- Scope chips have no real scope effect.
|
||||
- Runs and Operations are used as competing primary collection nouns.
|
||||
- Long workflow labels live in dense tables.
|
||||
- Edit is used as default inspect even though a true View surface exists.
|
||||
- Queue surfaces throw the operator out of context through row click.
|
||||
- A workbench surface mixes scope, selection, navigation, and object
|
||||
actions as one flat header rail.
|
||||
- Critical health or operability truth is hidden by default.
|
||||
- A contract claims conformance while the rendered UI behaves differently.
|
||||
- Header has multiple equally weighted buttons without clear prioritization.
|
||||
@ -893,6 +1334,9 @@ ### Scope, Compliance, and Review Expectations
|
||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||
affected surface under DECIDE-001 and justify any new Primary
|
||||
Decision Surface or workflow-first navigation change.
|
||||
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||
|
||||
@ -906,4 +1350,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||
**Version**: 2.3.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-12
|
||||
|
||||
@ -58,6 +58,14 @@ ## Constitution Check
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||
- Decision-first operating model (DECIDE-001): each changed
|
||||
operator-facing surface is classified as Primary Decision,
|
||||
Secondary Context, or Tertiary Evidence / Diagnostics; primary
|
||||
surfaces justify the human-in-the-loop moment, default-visible info
|
||||
is limited to first-decision needs, deep proof is progressive
|
||||
disclosed, one governance case stays decidable in one context where
|
||||
practical, navigation follows workflows not storage structures, and
|
||||
automation / alerts reduce attention load instead of adding noise
|
||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||
@ -71,7 +79,14 @@ ## Constitution Check
|
||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
||||
- Action-surface discipline (ACTSURF-001 / HDR-001): every changed
|
||||
surface declares one broad action-surface class; the spec names the
|
||||
one likely next operator action; navigation is separated from
|
||||
mutation; record/detail/edit pages keep at most one visible primary
|
||||
header action; monitoring/workbench surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions; risky or rare
|
||||
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||
type or workflow-hub exception is explicit and justified
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
@ -35,22 +35,37 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing surface,
|
||||
fill out one row per affected surface. This role is orthogonal to the
|
||||
Action Surface Class / Surface Type below.
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||
fill out one row per affected surface.
|
||||
fill out one row per affected surface. Declare the broad Action Surface
|
||||
Class first, then the detailed Surface Type. Keep this table in sync
|
||||
with the Decision-First Surface Role section above.
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | CRUD / List-first Resource | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | List / Table / Bulk | CRUD / List-first Resource | Open policy for review | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||
If this feature adds a new operator-facing page or materially refactors
|
||||
one, fill out one row per affected page/surface. The contract MUST show
|
||||
how one governance case or operator task becomes decidable without
|
||||
unnecessary cross-page reconstruction.
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | Decide whether policy state needs follow-up | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
@ -199,19 +214,50 @@ ## Requirements *(mandatory)*
|
||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen surface type and why it is the correct classification,
|
||||
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||
- whether each affected surface is a Primary Decision Surface,
|
||||
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||
Surface, and why,
|
||||
- which human-in-the-loop moment each primary surface supports,
|
||||
- what MUST be visible immediately for the first decision,
|
||||
- what is preserved but only revealed on demand,
|
||||
- why any new primary surface cannot live inside an existing decision
|
||||
context,
|
||||
- how navigation follows operator workflows rather than storage
|
||||
structures,
|
||||
- how one governance case remains decidable in one focused context,
|
||||
- how any new automation, notifications, or autonomous governance logic
|
||||
reduce search/review/click load,
|
||||
- and how the resulting default experience is calmer and clearer rather
|
||||
than merely larger.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen broad action-surface class and why it is the correct classification,
|
||||
- the chosen detailed surface type and why it is the correct refinement,
|
||||
- the one most likely next operator action,
|
||||
- the one and only primary inspect/open model,
|
||||
- whether row click is required, allowed, or forbidden,
|
||||
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||
- where pure navigation lives and why it is not competing with mutation,
|
||||
- where secondary actions live,
|
||||
- where destructive actions live,
|
||||
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||
- the canonical collection route and canonical detail route,
|
||||
- the scope signals shown to the operator and what real effect each one has,
|
||||
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||
- which critical operational truth is visible by default,
|
||||
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||
feature adds or materially changes header actions, row actions, bulk
|
||||
actions, or workbench controls, the spec MUST describe:
|
||||
- how navigation, mutation, context signals, selection actions, and
|
||||
dangerous actions are separated,
|
||||
- why any visible secondary action deserves primary-plane placement,
|
||||
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||
- and why any workflow-hub, wizard, system, or other special-type
|
||||
exception is genuine rather than a convenience shortcut.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
|
||||
@ -39,30 +39,62 @@ # Tasks: [FEATURE NAME]
|
||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||
- removing implementation-first wording from primary operator-facing copy.
|
||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||
- classifying each affected surface as Primary Decision, Secondary
|
||||
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||
sync with the governing spec,
|
||||
- defining the human-in-the-loop moment and justifying any new Primary
|
||||
Decision Surface against existing decision contexts,
|
||||
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- keeping default-visible content limited to first-decision needs and
|
||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- keeping each governance case decidable in one focused context where
|
||||
practical instead of forcing cross-page reconstruction,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||
- keeping navigation aligned to operator workflows rather than storage
|
||||
structures,
|
||||
- ensuring new automation, alerts, or autonomous flows reduce
|
||||
search/review/click load instead of adding noise, extra lists, or
|
||||
extra detail work,
|
||||
- preserving a calm, prioritized default state that distinguishes
|
||||
actionable work from worth-watching context and reference-only
|
||||
information,
|
||||
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||
- assigning exactly one broad action-surface class to every changed
|
||||
operator-facing surface and keeping the detailed surface type in sync
|
||||
with the spec,
|
||||
- identifying the one likely next operator action for each changed
|
||||
surface and shaping the visible hierarchy around it,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||
- separating navigation from mutation so pure context changes do not
|
||||
compete visually with state-changing actions,
|
||||
- moving additional secondary actions into More or the detail header,
|
||||
- ordering visible actions and grouped actions by meaning, frequency,
|
||||
and risk rather than append order,
|
||||
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||
- ensuring workbench and monitoring surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions instead of mixing
|
||||
them into one flat header zone,
|
||||
- grouping bulk actions via BulkActionGroup,
|
||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- adding `AuditLog` entries for relevant mutations,
|
||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||
- documenting any workflow-hub, wizard, utility/system, or other
|
||||
special-type exception in the spec/PR and adding dedicated test
|
||||
coverage,
|
||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||
@ -71,8 +103,13 @@ # Tasks: [FEATURE NAME]
|
||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
|
||||
- enforcing ACTSURF-001 / HDR-001 action discipline: record/detail/edit
|
||||
pages keep at most 1 visible primary header action; pure navigation
|
||||
moves to contextual placement; destructive or governance-changing
|
||||
actions are separated and require friction; monitoring/workbench
|
||||
surfaces use their own layered hierarchy; rare actions live in
|
||||
structured Action Groups; every affected surface passes the few-second
|
||||
scan rule,
|
||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
|
||||
@ -289,7 +289,9 @@ public function refreshMatrix(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($user instanceof User, 403);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -191,10 +192,12 @@ protected function getHeaderActions(): array
|
||||
|
||||
$selectedDecisionActions = [
|
||||
Action::make('approve_selected_exception')
|
||||
->label('Approve exception')
|
||||
->label(GovernanceActionCatalog::rule('approve_exception')->canonicalLabel)
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('approve_exception')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('approve_exception')->modalDescription)
|
||||
->form([
|
||||
DateTimePicker::make('effective_from')
|
||||
->label('Effective from')
|
||||
@ -207,6 +210,7 @@ protected function getHeaderActions(): array
|
||||
Textarea::make('approval_reason')
|
||||
->label('Approval reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
@ -223,16 +227,18 @@ protected function getHeaderActions(): array
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||
->title($wasRenewalRequest ? 'Exception renewed' : GovernanceActionCatalog::rule('approve_exception')->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('reject_selected_exception')
|
||||
->label('Reject exception')
|
||||
->color('danger')
|
||||
->label(GovernanceActionCatalog::rule('reject_exception')->canonicalLabel)
|
||||
->color('warning')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('reject_exception')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('reject_exception')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('rejection_reason')
|
||||
->label('Rejection reason')
|
||||
@ -254,7 +260,7 @@ protected function getHeaderActions(): array
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||
->title($wasRenewalRequest ? 'Renewal rejected' : GovernanceActionCatalog::rule('reject_exception')->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
@ -69,7 +69,7 @@ private function captureAction(): Action
|
||||
->label($label)
|
||||
->icon('heroicon-o-camera')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
|
||||
->hidden(fn (): bool => $this->shouldHideCaptureAction())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
@ -469,6 +469,15 @@ private function profileHasConsumableSnapshot(): bool
|
||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||
}
|
||||
|
||||
private function shouldHideCaptureAction(): bool
|
||||
{
|
||||
if (! $this->profileHasConsumableSnapshot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getEligibleCompareTenantOptions() !== [];
|
||||
}
|
||||
|
||||
private function compareAssignedTenantsDisabledReason(): ?string
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
|
||||
@ -31,11 +31,13 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -314,21 +316,36 @@ public static function table(Table $table): Table
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire snapshot')
|
||||
->label(GovernanceActionCatalog::rule('expire_snapshot')->canonicalLabel)
|
||||
->color('danger')
|
||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||
->requiresConfirmation()
|
||||
->action(function (EvidenceSnapshot $record): void {
|
||||
->modalHeading(GovernanceActionCatalog::rule('expire_snapshot')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('expire_snapshot')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('expiration_reason')
|
||||
->label('Expiry reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (EvidenceSnapshot $record, array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
app(EvidenceSnapshotService::class)->expire(
|
||||
$record,
|
||||
$user,
|
||||
(string) ($data['expiration_reason'] ?? ''),
|
||||
);
|
||||
static::truthEnvelope($record->refresh(), fresh: true);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
Notification::make()->success()->title(
|
||||
GovernanceActionCatalog::rule('expire_snapshot')->successTitle,
|
||||
)->send();
|
||||
}),
|
||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||
)
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -25,14 +27,19 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_snapshot')
|
||||
->label('Refresh evidence')
|
||||
Actions\Action::make('refresh_evidence')
|
||||
->label($refreshRule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
->modalHeading($refreshRule->modalHeading)
|
||||
->modalDescription($refreshRule->modalDescription)
|
||||
->action(function () use ($refreshRule): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -41,29 +48,42 @@ protected function getHeaderActions(): array
|
||||
|
||||
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
||||
|
||||
Notification::make()->success()->title('Refresh evidence queued')->send();
|
||||
Notification::make()->success()->title($refreshRule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('expire_snapshot')
|
||||
->label('Expire snapshot')
|
||||
->label($expireRule->canonicalLabel)
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
->modalHeading($expireRule->modalHeading)
|
||||
->modalDescription($expireRule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('expiration_reason')
|
||||
->label('Expiry reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data) use ($expireRule): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
||||
app(EvidenceSnapshotService::class)->expire(
|
||||
$this->record,
|
||||
$user,
|
||||
(string) ($data['expiration_reason'] ?? ''),
|
||||
);
|
||||
$this->refreshFormData(['status', 'expires_at']);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
Notification::make()->success()->title($expireRule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
@ -32,9 +33,12 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
||||
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
|
||||
|
||||
return [
|
||||
Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->label($renewRule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||
@ -42,6 +46,8 @@ protected function getHeaderActions(): array
|
||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->modalHeading($renewRule->modalHeading)
|
||||
->modalDescription($renewRule->modalDescription)
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
@ -84,7 +90,7 @@ protected function getHeaderActions(): array
|
||||
->defaultItems(0)
|
||||
->collapsed(),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
->action(function (array $data, FindingExceptionService $service) use ($renewRule): void {
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -105,18 +111,20 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Renewal request submitted')
|
||||
->title($renewRule->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
||||
}),
|
||||
Action::make('revoke_exception')
|
||||
->label('Revoke exception')
|
||||
->label($revokeRule->canonicalLabel)
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($revokeRule->modalHeading)
|
||||
->modalDescription($revokeRule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
->label('Revocation reason')
|
||||
@ -124,7 +132,7 @@ protected function getHeaderActions(): array
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data, FindingExceptionService $service): void {
|
||||
->action(function (array $data, FindingExceptionService $service) use ($revokeRule): void {
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -145,7 +153,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Exception revoked')
|
||||
->title($revokeRule->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
@ -1121,8 +1122,10 @@ public static function table(Table $table): Table
|
||||
BulkAction::make('close_selected')
|
||||
->label('Close selected')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
->label('Close reason')
|
||||
@ -1441,13 +1444,17 @@ public static function resolveAction(): Actions\Action
|
||||
|
||||
public static function closeAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('close_finding');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('close')
|
||||
->label('Close')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
->label('Close reason')
|
||||
@ -1455,10 +1462,10 @@ public static function closeAction(): Actions\Action
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding closed',
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
|
||||
$finding,
|
||||
$tenant,
|
||||
@ -1537,16 +1544,20 @@ public static function requestExceptionAction(): Actions\Action
|
||||
|
||||
public static function renewExceptionAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('renew_exception');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->color('primary')
|
||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
@ -1601,13 +1612,17 @@ public static function renewExceptionAction(): Actions\Action
|
||||
|
||||
public static function revokeExceptionAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('revoke_exception');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('revoke_exception')
|
||||
->label('Revoke exception')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
->label('Revocation reason')
|
||||
@ -1627,18 +1642,34 @@ public static function revokeExceptionAction(): Actions\Action
|
||||
|
||||
public static function reopenAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('reopen_finding');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('reopen')
|
||||
->label('Reopen')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('warning')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
->form([
|
||||
Textarea::make('reopen_reason')
|
||||
->label('Reopen reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding reopened',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user),
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
(string) ($data['reopen_reason'] ?? ''),
|
||||
),
|
||||
);
|
||||
})
|
||||
)
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -85,7 +86,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one primary consent CTA while shared connection actions remain grouped under "More".');
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
@ -706,7 +707,11 @@ public static function table(Table $table): Table
|
||||
->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_health_check_at')
|
||||
->label('Last check')
|
||||
->since()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@ -803,306 +808,122 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\EditAction::make()
|
||||
static::makeEditNavigationAction(),
|
||||
static::makeCheckConnectionAction(),
|
||||
static::makeInventorySyncAction(),
|
||||
static::makeComplianceSnapshotAction(),
|
||||
static::makeSetDefaultAction(),
|
||||
static::makeEnableDedicatedOverrideAction(
|
||||
source: 'provider_connection.resource_table',
|
||||
modalDescription: 'Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.',
|
||||
),
|
||||
static::makeRotateDedicatedCredentialAction(),
|
||||
static::makeDeleteDedicatedCredentialAction(),
|
||||
static::makeRevertToPlatformAction(source: 'provider_connection.resource_table'),
|
||||
static::makeEnableConnectionAction(),
|
||||
static::makeDisableConnectionAction(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function makeEditNavigationAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (ProviderConnection $record): string => static::getUrl('edit', ['record' => $record], tenant: static::resolveTenantForRecord($record)))
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeCheckConnectionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('check_connection')
|
||||
->label('Check connection')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
->visible(fn (ProviderConnection $record): bool => static::recordAllowsProviderExecution($record))
|
||||
->action(function (ProviderConnection $record, StartVerification $verification, $livewire = null): void {
|
||||
static::handleCheckConnectionAction($record, $verification, $livewire);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeInventorySyncAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('inventory_sync')
|
||||
->label('Inventory sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
->visible(fn (ProviderConnection $record): bool => static::recordAllowsProviderExecution($record))
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, $livewire = null): void {
|
||||
static::handleProviderOperationAction(
|
||||
record: $record,
|
||||
gate: $gate,
|
||||
operationType: 'inventory_sync',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
blockedTitle: 'Inventory sync blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
livewire: $livewire,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope is busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->danger()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeComplianceSnapshotAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('compliance_snapshot')
|
||||
->label('Compliance snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
->visible(fn (ProviderConnection $record): bool => static::recordAllowsProviderExecution($record))
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, $livewire = null): void {
|
||||
static::handleProviderOperationAction(
|
||||
record: $record,
|
||||
gate: $gate,
|
||||
operationType: 'compliance.snapshot',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
blockedTitle: 'Compliance snapshot blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderComplianceSnapshotJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
livewire: $livewire,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope is busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->danger()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeSetDefaultAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('set_default')
|
||||
->label('Set as default')
|
||||
->icon('heroicon-o-star')
|
||||
@ -1148,15 +969,16 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('enable_dedicated_override')
|
||||
public static function makeEnableDedicatedOverrideAction(string $source, ?string $modalDescription = null): Actions\Action
|
||||
{
|
||||
$action = 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 (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
@ -1169,7 +991,7 @@ public static function table(Table $table): Table
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -1198,7 +1020,7 @@ public static function table(Table $table): Table
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => 'provider_connection.resource_table',
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -1213,14 +1035,21 @@ public static function table(Table $table): Table
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
if (is_string($modalDescription) && $modalDescription !== '') {
|
||||
$action->modalDescription($modalDescription);
|
||||
}
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('rotate_dedicated_credential')
|
||||
public static function makeRotateDedicatedCredentialAction(?string $modalDescription = null): Actions\Action
|
||||
{
|
||||
$action = Actions\Action::make('rotate_dedicated_credential')
|
||||
->label('Rotate dedicated credential')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
@ -1259,14 +1088,21 @@ public static function table(Table $table): Table
|
||||
->title('Dedicated credential rotated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
if (is_string($modalDescription) && $modalDescription !== '') {
|
||||
$action->modalDescription($modalDescription);
|
||||
}
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('delete_dedicated_credential')
|
||||
public static function makeDeleteDedicatedCredentialAction(?string $modalDescription = null): Actions\Action
|
||||
{
|
||||
$action = Actions\Action::make('delete_dedicated_credential')
|
||||
->label('Delete dedicated credential')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
@ -1286,20 +1122,27 @@ public static function table(Table $table): Table
|
||||
->title('Dedicated credential deleted')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
if (is_string($modalDescription) && $modalDescription !== '') {
|
||||
$action->modalDescription($modalDescription);
|
||||
}
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('revert_to_platform')
|
||||
public static function makeRevertToPlatformAction(string $source, ?string $modalDescription = null): Actions\Action
|
||||
{
|
||||
$action = 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 {
|
||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -1323,7 +1166,7 @@ public static function table(Table $table): Table
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => 'provider_connection.resource_table',
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
@ -1338,13 +1181,21 @@ public static function table(Table $table): Table
|
||||
->title('Connection reverted to platform')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
if (is_string($modalDescription) && $modalDescription !== '') {
|
||||
$action->modalDescription($modalDescription);
|
||||
}
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeEnableConnectionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('enable_connection')
|
||||
->label('Enable connection')
|
||||
->icon('heroicon-o-play')
|
||||
@ -1416,9 +1267,12 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
UiEnforcement::forAction(
|
||||
public static function makeDisableConnectionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('disable_connection')
|
||||
->label('Disable connection')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
@ -1470,13 +1324,190 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
->apply();
|
||||
}
|
||||
|
||||
private static function recordAllowsProviderExecution(ProviderConnection $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& (bool) $record->is_enabled;
|
||||
}
|
||||
|
||||
private static function handleCheckConnectionAction(ProviderConnection $record, StartVerification $verification, mixed $livewire = null): void
|
||||
{
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Actions\Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(static::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(Tenant, User, ProviderConnection, OperationRun): void $dispatcher
|
||||
*/
|
||||
private static function handleProviderOperationAction(
|
||||
ProviderConnection $record,
|
||||
ProviderOperationStartGate $gate,
|
||||
string $operationType,
|
||||
string $blockedTitle,
|
||||
callable $dispatcher,
|
||||
mixed $livewire = null,
|
||||
): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: $operationType,
|
||||
dispatcher: function (OperationRun $operationRun) use ($dispatcher, $record, $tenant, $user): void {
|
||||
$dispatcher($tenant, $user, $record, $operationRun);
|
||||
},
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope is busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->danger()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title($blockedTitle)
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
|
||||
@ -199,738 +199,26 @@ protected function getHeaderActions(): array
|
||||
->tooltip('You do not have permission to view provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('check_connection')
|
||||
->label('Check connection')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
Action::make('manage_connections')
|
||||
->label('Manage Provider Connections')
|
||||
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
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 (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $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, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mutations->enableDedicatedOverride(
|
||||
connection: $record,
|
||||
clientId: (string) $data['client_id'],
|
||||
clientSecret: (string) $data['client_secret'],
|
||||
);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.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,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Dedicated override enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->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(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('set_default')
|
||||
->label('Set as default')
|
||||
->icon('heroicon-o-star')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& (bool) $record->is_enabled
|
||||
&& ! $record->is_default
|
||||
&& ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('provider', $record->provider)
|
||||
->count() > 1)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$record->makeDefault();
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.default_set',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Default connection updated')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('inventory_sync')
|
||||
->label('Inventory sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'inventory_sync',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope is busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->danger()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('compliance_snapshot')
|
||||
->label('Compliance snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'compliance.snapshot',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderComplianceSnapshotJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
Notification::make()
|
||||
->title('Scope is busy')
|
||||
->body('Another provider operation is already running for this connection.')
|
||||
->danger()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('enable_connection')
|
||||
->label('Enable connection')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
$verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked;
|
||||
$errorReasonCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
if (! $hadCredentials) {
|
||||
$errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing;
|
||||
$errorMessage = 'Provider connection credentials are missing.';
|
||||
}
|
||||
|
||||
$record->update([
|
||||
'is_enabled' => true,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => $errorReasonCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.enabled',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'enabled',
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'credentials_present' => $hadCredentials,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
if (! $hadCredentials) {
|
||||
Notification::make()
|
||||
->title('Connection enabled (credentials missing)')
|
||||
->body('Add credentials before running checks or operations.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Provider connection enabled')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('disable_connection')
|
||||
->label('Disable connection')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
|
||||
$record->update([
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||
$actorEmail = $user instanceof User ? $user->email : null;
|
||||
$actorName = $user instanceof User ? $user->name : null;
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.disabled',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'disabled',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'provider_connection',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Provider connection disabled')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
ProviderConnectionResource::makeCheckConnectionAction(),
|
||||
ProviderConnectionResource::makeInventorySyncAction(),
|
||||
ProviderConnectionResource::makeComplianceSnapshotAction(),
|
||||
ProviderConnectionResource::makeSetDefaultAction(),
|
||||
ProviderConnectionResource::makeEnableDedicatedOverrideAction(
|
||||
source: 'provider_connection.edit_page',
|
||||
modalDescription: 'Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.',
|
||||
),
|
||||
ProviderConnectionResource::makeRotateDedicatedCredentialAction(
|
||||
modalDescription: 'Stores a replacement dedicated client secret and refreshes dedicated identity state.',
|
||||
),
|
||||
ProviderConnectionResource::makeDeleteDedicatedCredentialAction(
|
||||
modalDescription: 'Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.',
|
||||
),
|
||||
ProviderConnectionResource::makeRevertToPlatformAction(
|
||||
source: 'provider_connection.edit_page',
|
||||
modalDescription: 'Reverts the connection to the platform-managed identity and removes any dedicated credential.',
|
||||
),
|
||||
ProviderConnectionResource::makeEnableConnectionAction(),
|
||||
ProviderConnectionResource::makeDisableConnectionAction(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -3,17 +3,12 @@
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
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
|
||||
@ -22,228 +17,59 @@ class ViewProviderConnection extends ViewRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
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);
|
||||
|
||||
->url(function () use ($tenant): ?string {
|
||||
return $tenant instanceof Tenant
|
||||
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||
: null;
|
||||
})
|
||||
->visible(function (): bool {
|
||||
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
|
||||
})
|
||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
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')
|
||||
Actions\ActionGroup::make($this->sharedConnectionActions())
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Actions\Action>
|
||||
*/
|
||||
private function sharedConnectionActions(): array
|
||||
{
|
||||
return [
|
||||
ProviderConnectionResource::makeEditNavigationAction(),
|
||||
ProviderConnectionResource::makeCheckConnectionAction(),
|
||||
ProviderConnectionResource::makeInventorySyncAction(),
|
||||
ProviderConnectionResource::makeComplianceSnapshotAction(),
|
||||
ProviderConnectionResource::makeSetDefaultAction(),
|
||||
ProviderConnectionResource::makeEnableDedicatedOverrideAction(
|
||||
source: 'provider_connection.view_page',
|
||||
modalDescription: 'Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.',
|
||||
),
|
||||
ProviderConnectionResource::makeRotateDedicatedCredentialAction(),
|
||||
ProviderConnectionResource::makeDeleteDedicatedCredentialAction(),
|
||||
ProviderConnectionResource::makeRevertToPlatformAction(source: 'provider_connection.view_page'),
|
||||
ProviderConnectionResource::makeEnableConnectionAction(),
|
||||
ProviderConnectionResource::makeDisableConnectionAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
{
|
||||
if (! $this->record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,8 @@
|
||||
use App\Services\Intune\RbacOnboardingService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Tenants\TenantActionPolicySurface;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
@ -61,6 +61,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -178,7 +179,423 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
|
||||
->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 remains the workflow-heavy special type: pure navigation moves into contextual related content while header actions stay grouped into external-link, setup, and lifecycle buckets.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view remains the workflow-heavy special type: shared administrative actions stay grouped into external-link, setup, triage, and lifecycle buckets, while navigation-only context stays outside the header action strip.');
|
||||
}
|
||||
|
||||
public static function makeAdminConsentAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record): string => static::adminConsentUrl($record) ?? '#')
|
||||
->visible(fn (Tenant $record): bool => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function makeOpenInEntraAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record): string => static::entraUrl($record) ?? '#')
|
||||
->visible(fn (Tenant $record): bool => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
public static function makeSyncTenantAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => static::syncActionVisible($record))
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, $livewire = null): void {
|
||||
static::handleSyncTenantAction($record, $auditLogger, $livewire);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function makeVerifyConfigurationAction(string $surfaceKind = 'tenant_list_row'): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
||||
->action(function (Tenant $record, StartVerification $verification, $livewire = null) use ($surfaceKind): void {
|
||||
static::handleVerifyConfigurationAction($record, $verification, $livewire, $surfaceKind);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function tenantViewTriageState(): array
|
||||
{
|
||||
return static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
}
|
||||
|
||||
public static function tenantViewTriageGroupVisible(Tenant $tenant): bool
|
||||
{
|
||||
return static::selectedActionTriageReviewRowForTenant($tenant, static::tenantViewTriageState()) !== null
|
||||
&& static::userCanSeeTriageReviewAction($tenant);
|
||||
}
|
||||
|
||||
public static function makeTenantViewMarkReviewedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('markReviewed')
|
||||
->label('Mark reviewed')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark reviewed')
|
||||
->modalDescription(fn (Tenant $record): string => static::triageReviewActionModalDescription(
|
||||
$record,
|
||||
static::tenantViewTriageState(),
|
||||
TenantTriageReview::STATE_REVIEWED,
|
||||
))
|
||||
->visible(fn (Tenant $record): bool => static::selectedActionTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::tenantViewTriageState(),
|
||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
|
||||
->before(function (Tenant $record): void {
|
||||
static::authorizeTriageReviewAction($record);
|
||||
})
|
||||
->action(function (Tenant $record, TenantTriageReviewService $service): void {
|
||||
static::handleTriageReviewMutation(
|
||||
tenant: $record,
|
||||
triageState: static::tenantViewTriageState(),
|
||||
targetManualState: TenantTriageReview::STATE_REVIEWED,
|
||||
service: $service,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static function makeTenantViewMarkFollowUpNeededAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('markFollowUpNeeded')
|
||||
->label('Mark follow-up needed')
|
||||
->icon('heroicon-o-exclamation-circle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark follow-up needed')
|
||||
->modalDescription(fn (Tenant $record): string => static::triageReviewActionModalDescription(
|
||||
$record,
|
||||
static::tenantViewTriageState(),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
))
|
||||
->visible(fn (Tenant $record): bool => static::selectedActionTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::tenantViewTriageState(),
|
||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
|
||||
->before(function (Tenant $record): void {
|
||||
static::authorizeTriageReviewAction($record);
|
||||
})
|
||||
->action(function (Tenant $record, TenantTriageReviewService $service): void {
|
||||
static::handleTriageReviewMutation(
|
||||
tenant: $record,
|
||||
triageState: static::tenantViewTriageState(),
|
||||
targetManualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
service: $service,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static function makeRestoreTenantAction(TenantActionSurface $surface, ?string $permissionTooltip = null): Actions\Action
|
||||
{
|
||||
$builder = UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (): string => GovernanceActionCatalog::rule('restore_tenant')->canonicalLabel)
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, $surface)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle(fn (): string => GovernanceActionCatalog::rule('restore_tenant')->successTitle)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('restore_tenant')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('restore_tenant')->modalDescription)
|
||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, $surface)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE);
|
||||
|
||||
if ($permissionTooltip !== null && $permissionTooltip !== '') {
|
||||
$builder->tooltip($permissionTooltip);
|
||||
}
|
||||
|
||||
return $builder->apply();
|
||||
}
|
||||
|
||||
public static function makeArchiveTenantAction(TenantActionSurface $surface, ?string $permissionTooltip = null): Actions\Action
|
||||
{
|
||||
$builder = UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label(fn (): string => GovernanceActionCatalog::rule('archive_tenant')->canonicalLabel)
|
||||
->color('danger')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, $surface)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->successNotificationTitle(fn (): string => GovernanceActionCatalog::rule('archive_tenant')->successTitle)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('archive_tenant')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('archive_tenant')->modalDescription)
|
||||
->form([
|
||||
Forms\Components\Textarea::make('archive_reason')
|
||||
->label('Archive reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, $surface)?->key === 'archive')
|
||||
->action(function (Tenant $record, array $data, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::archiveTenant($record, $auditLogger, (string) ($data['archive_reason'] ?? ''));
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE);
|
||||
|
||||
if ($permissionTooltip !== null && $permissionTooltip !== '') {
|
||||
$builder->tooltip($permissionTooltip);
|
||||
}
|
||||
|
||||
return $builder->apply();
|
||||
}
|
||||
|
||||
private static function syncActionVisible(Tenant $record): bool
|
||||
{
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($record);
|
||||
}
|
||||
|
||||
private static function handleSyncTenantAction(Tenant $record, AuditLogger $auditLogger, mixed $livewire = null): 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_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: $user,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function handleVerifyConfigurationAction(
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
mixed $livewire = null,
|
||||
string $surfaceKind = 'tenant_list_row',
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$result = $verification->providerConnectionCheckForTenant(
|
||||
tenant: $record,
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'surface' => [
|
||||
'kind' => $surfaceKind,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active operation to finish.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$actions = [
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
];
|
||||
|
||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
|
||||
foreach ($nextSteps as $index => $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
|
||||
if ($label === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||
->label($label)
|
||||
->url($url);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function userCanManageAnyTenant(User $user): bool
|
||||
@ -341,11 +758,13 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policies_count')
|
||||
->label('Policies')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_policy_sync_at')
|
||||
->label('Last Sync')
|
||||
->since()
|
||||
->sortable(),
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('domain')
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@ -464,260 +883,10 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('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)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): 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_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
\Filament\Tables\Contracts\HasTable $livewire,
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$result = $verification->providerConnectionCheckForTenant(
|
||||
tenant: $record,
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'surface' => [
|
||||
'kind' => 'tenant_list_row',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active operation to finish.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$actions = [
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
];
|
||||
|
||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
|
||||
foreach ($nextSteps as $index => $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
|
||||
if ($label === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||
->label($label)
|
||||
->url($url);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
static::makeAdminConsentAction(),
|
||||
static::makeOpenInEntraAction(),
|
||||
static::makeSyncTenantAction(),
|
||||
static::makeVerifyConfigurationAction(),
|
||||
Actions\Action::make('markReviewed')
|
||||
->label('Mark reviewed')
|
||||
->icon('heroicon-o-check-circle')
|
||||
@ -782,23 +951,7 @@ public static function table(Table $table): Table
|
||||
service: $service,
|
||||
);
|
||||
}),
|
||||
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::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
|
||||
static::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
@ -856,23 +1009,7 @@ public static function table(Table $table): Table
|
||||
->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::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
@ -2482,9 +2619,10 @@ private static function viewerHasTenantCapability(Tenant $tenant, string $capabi
|
||||
&& $resolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger, string $reason): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$reason = static::validatedLifecycleReason($reason, 'archive_reason');
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
@ -2519,11 +2657,11 @@ public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $audit
|
||||
tenant: $record,
|
||||
action: AuditActionId::TenantArchived,
|
||||
actor: $user,
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id, 'reason' => $reason]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
|
||||
->title(GovernanceActionCatalog::rule('archive_tenant')->successTitle)
|
||||
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
|
||||
->success()
|
||||
->send();
|
||||
@ -2570,12 +2708,27 @@ public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $audit
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
|
||||
->title(GovernanceActionCatalog::rule('restore_tenant')->successTitle)
|
||||
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function validatedLifecycleReason(string $reason, string $field): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if ($reason === '') {
|
||||
throw new \InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
if (mb_strlen($reason) > 2000) {
|
||||
throw new \InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -4,12 +4,8 @@
|
||||
|
||||
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;
|
||||
|
||||
class EditTenant extends EditRecord
|
||||
@ -20,42 +16,14 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
Actions\ActionGroup::make([
|
||||
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(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()
|
||||
->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)
|
||||
->tooltip('You do not have permission to archive tenants.')
|
||||
->preserveVisibility()
|
||||
->destructive()
|
||||
->apply(),
|
||||
TenantResource::makeRestoreTenantAction(
|
||||
TenantActionSurface::TenantEditHeader,
|
||||
'You do not have permission to restore tenants.',
|
||||
),
|
||||
TenantResource::makeArchiveTenantAction(
|
||||
TenantActionSurface::TenantEditHeader,
|
||||
'You do not have permission to archive tenants.',
|
||||
),
|
||||
])
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
|
||||
@ -10,9 +10,7 @@
|
||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
@ -57,18 +55,8 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\Action::make('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)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
TenantResource::makeAdminConsentAction(),
|
||||
TenantResource::makeOpenInEntraAction(),
|
||||
])
|
||||
->label('External links')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
@ -76,126 +64,8 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label(self::verificationHeaderActionLabel())
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$result = $verification->providerConnectionCheckForTenant(
|
||||
tenant: $record,
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'surface' => [
|
||||
'kind' => 'tenant_view_header',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active operation to finish.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$actions = [
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
];
|
||||
|
||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
|
||||
foreach ($nextSteps as $index => $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||
|
||||
if ($label === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||
->label($label)
|
||||
->url($url);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
TenantResource::makeSyncTenantAction(),
|
||||
TenantResource::makeVerifyConfigurationAction('tenant_view_header'),
|
||||
TenantResource::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_rbac')
|
||||
@ -271,40 +141,17 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
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(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
|
||||
->color('danger')
|
||||
->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()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
TenantResource::makeTenantViewMarkReviewedAction(),
|
||||
TenantResource::makeTenantViewMarkFollowUpNeededAction(),
|
||||
])
|
||||
->label('Triage')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
||||
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
||||
])
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
|
||||
@ -13,7 +13,9 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -146,14 +148,18 @@ private function secondaryLifecycleActionNames(): array
|
||||
|
||||
private function refreshReviewAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('refresh_review');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_review')
|
||||
->label('Refresh review')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->action(function () use ($rule): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -168,7 +174,7 @@ private function refreshReviewAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->success()->title('Refresh review queued')->send();
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
@ -178,14 +184,25 @@ private function refreshReviewAction(): Actions\Action
|
||||
|
||||
private function publishReviewAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('publish_review');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('publish_review')
|
||||
->label('Publish review')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('publish_reason')
|
||||
->label('Publication reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data) use ($rule): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -193,7 +210,11 @@ private function publishReviewAction(): Actions\Action
|
||||
}
|
||||
|
||||
try {
|
||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
||||
app(TenantReviewLifecycleService::class)->publish(
|
||||
$this->record,
|
||||
$user,
|
||||
(string) ($data['publish_reason'] ?? ''),
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
||||
|
||||
@ -201,7 +222,7 @@ private function publishReviewAction(): Actions\Action
|
||||
}
|
||||
|
||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
||||
Notification::make()->success()->title('Review published')->send();
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
@ -259,24 +280,39 @@ private function createNextReviewAction(): Actions\Action
|
||||
|
||||
private function archiveReviewAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('archive_review');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('archive_review')
|
||||
->label('Archive review')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('archive_reason')
|
||||
->label('Archive reason')
|
||||
->rows(4)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (array $data) use ($rule): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
||||
app(TenantReviewLifecycleService::class)->archive(
|
||||
$this->record,
|
||||
$user,
|
||||
(string) ($data['archive_reason'] ?? ''),
|
||||
);
|
||||
$this->refreshFormData(['status', 'archived_at']);
|
||||
|
||||
Notification::make()->success()->title('Review archived')->send();
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -55,6 +56,10 @@ public function getTitle(): string|Htmlable
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$retryRule = GovernanceActionCatalog::rule('retry_run');
|
||||
$cancelRule = GovernanceActionCatalog::rule('cancel_run');
|
||||
$investigatedRule = GovernanceActionCatalog::rule('mark_investigated');
|
||||
|
||||
return [
|
||||
Action::make('show_all_operations')
|
||||
->label('Show all operations')
|
||||
@ -63,8 +68,11 @@ protected function getHeaderActions(): array
|
||||
->label('Go to runbooks')
|
||||
->url(Runbooks::getUrl(panel: 'system')),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->label($retryRule->canonicalLabel)
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($retryRule->modalHeading)
|
||||
->modalDescription($retryRule->modalDescription)
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
@ -79,37 +87,50 @@ protected function getHeaderActions(): array
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->label($cancelRule->canonicalLabel)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($cancelRule->modalHeading)
|
||||
->modalDescription($cancelRule->modalDescription)
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($this->run, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->label('Cancellation reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data, OperationRunTriageService $triageService): void {
|
||||
->action(function (array $data, OperationRunTriageService $triageService) use ($cancelRule): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($this->run, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title($cancelRule->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label($investigatedRule->canonicalLabel)
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($investigatedRule->modalHeading)
|
||||
->modalDescription($investigatedRule->modalDescription)
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Investigation reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data, OperationRunTriageService $triageService) use ($investigatedRule): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->title($investigatedRule->successTitle)
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
@ -37,6 +37,24 @@ class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSch
|
||||
*/
|
||||
public ?array $arrivalState = null;
|
||||
|
||||
private ?PortfolioArrivalContext $cachedArrivalContext = null;
|
||||
|
||||
private ?int $cachedArrivalContextTenantId = null;
|
||||
|
||||
private bool $hasCachedArrivalContext = false;
|
||||
|
||||
/**
|
||||
* @var array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}|null
|
||||
*/
|
||||
private ?array $cachedConcernTruth = null;
|
||||
|
||||
private ?int $cachedConcernTruthTenantId = null;
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, array<string, mixed>|null>>
|
||||
*/
|
||||
private array $cachedReviewStates = [];
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
@ -197,23 +215,22 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
|
||||
return;
|
||||
}
|
||||
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
$concernTruth = $this->concernTruthFor($tenant);
|
||||
$actor = auth()->user();
|
||||
|
||||
$review = match ($targetManualState) {
|
||||
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
|
||||
tenant: $tenant,
|
||||
concernFamily: $context->concernFamily,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
backupHealth: $concernTruth['backupHealth'],
|
||||
recoveryEvidence: $concernTruth['recoveryEvidence'],
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
|
||||
tenant: $tenant,
|
||||
concernFamily: $context->concernFamily,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
backupHealth: $concernTruth['backupHealth'],
|
||||
recoveryEvidence: $concernTruth['recoveryEvidence'],
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
default => null,
|
||||
@ -223,6 +240,8 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
|
||||
return;
|
||||
}
|
||||
|
||||
$this->clearConcernCachesFor($tenant);
|
||||
|
||||
Notification::make()
|
||||
->title('Review state updated')
|
||||
->body(sprintf(
|
||||
@ -240,15 +259,59 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
|
||||
*/
|
||||
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
|
||||
{
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
return app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||
if (array_key_exists($tenantId, $this->cachedReviewStates)
|
||||
&& array_key_exists($concernFamily, $this->cachedReviewStates[$tenantId])) {
|
||||
return $this->cachedReviewStates[$tenantId][$concernFamily];
|
||||
}
|
||||
|
||||
$concernTruth = $this->concernTruthFor($tenant);
|
||||
|
||||
$reviewState = app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantIds: [(int) $tenant->getKey()],
|
||||
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
|
||||
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
|
||||
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
|
||||
tenantIds: [$tenantId],
|
||||
backupHealthByTenant: [$tenantId => $concernTruth['backupHealth']],
|
||||
recoveryEvidenceByTenant: [$tenantId => $concernTruth['recoveryEvidence']],
|
||||
)['rows'][$tenantId][$concernFamily] ?? null;
|
||||
|
||||
$this->cachedReviewStates[$tenantId][$concernFamily] = $reviewState;
|
||||
|
||||
return $reviewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}
|
||||
*/
|
||||
private function concernTruthFor(Tenant $tenant): array
|
||||
{
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
if ($this->cachedConcernTruthTenantId === $tenantId && is_array($this->cachedConcernTruth)) {
|
||||
return $this->cachedConcernTruth;
|
||||
}
|
||||
|
||||
$this->cachedConcernTruthTenantId = $tenantId;
|
||||
$this->cachedConcernTruth = [
|
||||
'backupHealth' => app(TenantBackupHealthResolver::class)->assess($tenant),
|
||||
'recoveryEvidence' => app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant),
|
||||
];
|
||||
|
||||
return $this->cachedConcernTruth;
|
||||
}
|
||||
|
||||
private function clearConcernCachesFor(Tenant $tenant): void
|
||||
{
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
if ($this->cachedConcernTruthTenantId === $tenantId) {
|
||||
$this->cachedConcernTruthTenantId = null;
|
||||
$this->cachedConcernTruth = null;
|
||||
}
|
||||
|
||||
if (array_key_exists($tenantId, $this->cachedReviewStates)) {
|
||||
unset($this->cachedReviewStates[$tenantId]);
|
||||
}
|
||||
}
|
||||
|
||||
private function concernFamilyLabel(string $concernFamily): string
|
||||
@ -262,6 +325,31 @@ private function concernFamilyLabel(string $concernFamily): string
|
||||
|
||||
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
|
||||
{
|
||||
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
if ($this->arrivalState === null) {
|
||||
$this->cachedArrivalContextTenantId = $tenantId;
|
||||
$this->cachedArrivalContext = null;
|
||||
$this->hasCachedArrivalContext = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->hasCachedArrivalContext && $this->cachedArrivalContextTenantId === $tenantId) {
|
||||
return $this->cachedArrivalContext;
|
||||
}
|
||||
|
||||
$concernTruth = $this->concernTruthFor($tenant);
|
||||
|
||||
$this->cachedArrivalContextTenantId = $tenantId;
|
||||
$this->cachedArrivalContext = app(PortfolioArrivalContextResolver::class)->resolveStateWithTruth(
|
||||
$tenant,
|
||||
$this->arrivalState,
|
||||
$concernTruth['backupHealth'],
|
||||
$concernTruth['recoveryEvidence'],
|
||||
);
|
||||
$this->hasCachedArrivalContext = true;
|
||||
|
||||
return $this->cachedArrivalContext;
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,10 @@ public function __invoke(Request $request): RedirectResponse
|
||||
return redirect()->route('admin.operations.index');
|
||||
}
|
||||
|
||||
if ($this->isTenantScopedEvidencePath($previousPath)) {
|
||||
return redirect()->route('admin.evidence.overview');
|
||||
}
|
||||
|
||||
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
|
||||
$workspace = $workspaceContext->currentWorkspace($request);
|
||||
|
||||
@ -45,4 +49,17 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
return redirect()->to((string) $previousUrl);
|
||||
}
|
||||
|
||||
private function isTenantScopedEvidencePath(string $previousPath): bool
|
||||
{
|
||||
if ($previousPath === '/admin/evidence') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! str_starts_with($previousPath, '/admin/evidence/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! str_starts_with($previousPath, '/admin/evidence/overview');
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Barryvdh\Debugbar\LaravelDebugbar;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SuppressDebugbarForSmokeRequests
|
||||
{
|
||||
public const COOKIE_NAME = 'tp_smoke_test';
|
||||
|
||||
public const COOKIE_VALUE = 'ok';
|
||||
|
||||
public const SESSION_KEY = 'tp_smoke_test';
|
||||
|
||||
/**
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $this->shouldSuppressDebugbar($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$debugbar = app()->bound('debugbar') ? app('debugbar') : null;
|
||||
|
||||
config(['debugbar.enabled' => false]);
|
||||
|
||||
if ($debugbar instanceof LaravelDebugbar && $debugbar->isEnabled()) {
|
||||
$debugbar->disable();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldSuppressDebugbar(Request $request): bool
|
||||
{
|
||||
if ($request->cookie(self::COOKIE_NAME) === self::COOKIE_VALUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $request->hasSession()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->session()->get(self::SESSION_KEY) === self::COOKIE_VALUE;
|
||||
}
|
||||
}
|
||||
@ -125,8 +125,10 @@ public function refresh(EvidenceSnapshot $snapshot, User $user): EvidenceSnapsho
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
public function expire(EvidenceSnapshot $snapshot, User $user, string $reason): EvidenceSnapshot
|
||||
{
|
||||
$reason = $this->validatedReason($reason, 'expiration_reason');
|
||||
|
||||
$snapshot->forceFill([
|
||||
'status' => EvidenceSnapshotStatus::Expired->value,
|
||||
'expires_at' => now(),
|
||||
@ -142,6 +144,7 @@ public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
'metadata' => [
|
||||
'before_status' => EvidenceSnapshotStatus::Active->value,
|
||||
'after_status' => EvidenceSnapshotStatus::Expired->value,
|
||||
'reason' => $reason,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
@ -242,6 +245,25 @@ public function computeFingerprint(Tenant $tenant): string
|
||||
return $this->buildSnapshotPayload($tenant)['fingerprint'];
|
||||
}
|
||||
|
||||
private function validatedReason(mixed $reason, string $field): string
|
||||
{
|
||||
if (! is_string($reason)) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
$resolved = trim($reason);
|
||||
|
||||
if ($resolved === '') {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
if (mb_strlen($resolved) > 2000) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
public function checkActiveRun(Tenant $tenant): bool
|
||||
{
|
||||
return $this->operationRuns->findCanonicalRunWithIdentity(
|
||||
|
||||
@ -166,11 +166,10 @@ public function approve(FindingException $exception, User $actor, array $payload
|
||||
|
||||
$effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
|
||||
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
|
||||
$approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason');
|
||||
$approvedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $approvedException */
|
||||
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException {
|
||||
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $effectiveFrom, $expiresAt, $approvedAt): FindingException {
|
||||
/** @var FindingException $lockedException */
|
||||
$lockedException = FindingException::query()
|
||||
->with(['finding', 'tenant', 'requester', 'currentDecision'])
|
||||
@ -186,6 +185,8 @@ public function approve(FindingException $exception, User $actor, array $payload
|
||||
throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
|
||||
}
|
||||
|
||||
$approvalReason = $this->validatedReason($payload['approval_reason'] ?? null, 'approval_reason');
|
||||
|
||||
$isRenewalApproval = $lockedException->isPendingRenewal();
|
||||
$before = $this->exceptionSnapshot($lockedException);
|
||||
|
||||
@ -234,7 +235,7 @@ public function approve(FindingException $exception, User $actor, array $payload
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason),
|
||||
reason: $this->findingRiskAcceptedReason($approvalReason),
|
||||
);
|
||||
}
|
||||
|
||||
@ -695,15 +696,6 @@ private function validatedReason(mixed $reason, string $field): string
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function validatedOptionalReason(mixed $reason, string $field): ?string
|
||||
{
|
||||
if ($reason === null || $reason === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->validatedReason($reason, $field);
|
||||
}
|
||||
|
||||
private function validatedDate(mixed $value, string $field): CarbonImmutable
|
||||
{
|
||||
try {
|
||||
@ -842,15 +834,11 @@ private function evidenceSummary(array $references): array
|
||||
];
|
||||
}
|
||||
|
||||
private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string
|
||||
private function findingRiskAcceptedReason(string $approvalReason): string
|
||||
{
|
||||
if (is_string($approvalReason) && $approvalReason !== '') {
|
||||
return mb_substr($approvalReason, 0, 255);
|
||||
}
|
||||
|
||||
return 'Governed by approved exception #'.$exception->getKey();
|
||||
}
|
||||
|
||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||
{
|
||||
$currentDecision = $exception->relationLoaded('currentDecision')
|
||||
|
||||
@ -228,7 +228,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
||||
);
|
||||
}
|
||||
|
||||
public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -239,6 +239,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
||||
@ -251,6 +252,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $now->toIso8601String(),
|
||||
'reopened_reason' => $reason,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $dueAt->toIso8601String(),
|
||||
],
|
||||
|
||||
@ -108,17 +108,19 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
return $retryRun;
|
||||
}
|
||||
|
||||
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
public function cancel(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
|
||||
{
|
||||
if (! $this->canCancel($run)) {
|
||||
throw new InvalidArgumentException('Operation run is not cancelable.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'reason');
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'cancelled_at' => now()->toISOString(),
|
||||
'cancel_reason' => $reason,
|
||||
'cancelled_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
@ -141,6 +143,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
[
|
||||
'code' => 'run.cancelled',
|
||||
'message' => 'Run cancelled by platform operator triage action.',
|
||||
'reason' => $reason,
|
||||
],
|
||||
],
|
||||
);
|
||||
@ -150,6 +153,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
action: 'platform.system_console.cancel',
|
||||
metadata: [
|
||||
'operation_type' => (string) $run->type,
|
||||
'reason' => $reason,
|
||||
],
|
||||
run: $cancelledRun,
|
||||
);
|
||||
@ -159,11 +163,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
|
||||
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
|
||||
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
|
||||
}
|
||||
$reason = $this->validatedReason($reason, 'reason');
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
@ -199,4 +199,15 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function validatedReason(string $reason, string $field): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be between 5 and 500 characters.', $field));
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,10 +26,11 @@ public function __construct(
|
||||
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||
) {}
|
||||
|
||||
public function publish(TenantReview $review, User $user): TenantReview
|
||||
public function publish(TenantReview $review, User $user, string $reason): TenantReview
|
||||
{
|
||||
$review->loadMissing(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
$tenant = $review->tenant;
|
||||
$reason = $this->validatedReason($reason, 'publish_reason');
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
@ -59,6 +60,7 @@ public function publish(TenantReview $review, User $user): TenantReview
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'before_status' => $beforeStatus,
|
||||
'after_status' => TenantReviewStatus::Published->value,
|
||||
'reason' => $reason,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
@ -73,10 +75,11 @@ public function publish(TenantReview $review, User $user): TenantReview
|
||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
public function archive(TenantReview $review, User $user): TenantReview
|
||||
public function archive(TenantReview $review, User $user, string $reason): TenantReview
|
||||
{
|
||||
$review->loadMissing('tenant');
|
||||
$tenant = $review->tenant;
|
||||
$reason = $this->validatedReason($reason, 'archive_reason');
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
@ -101,6 +104,7 @@ public function archive(TenantReview $review, User $user): TenantReview
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'before_status' => $beforeStatus,
|
||||
'after_status' => TenantReviewStatus::Archived->value,
|
||||
'reason' => $reason,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
@ -171,6 +175,25 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
||||
return $nextReview;
|
||||
}
|
||||
|
||||
private function validatedReason(mixed $reason, string $field): string
|
||||
{
|
||||
if (! is_string($reason)) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
$resolved = trim($reason);
|
||||
|
||||
if ($resolved === '') {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
if (mb_strlen($resolved) > 2000) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function invalidateArtifactTruthCache(TenantReview $review): void
|
||||
{
|
||||
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');
|
||||
|
||||
@ -73,12 +73,34 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
||||
* }|null $state
|
||||
*/
|
||||
public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext
|
||||
{
|
||||
return $this->resolveStateWithTruth($tenant, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* sourceSurface: string,
|
||||
* tenantRouteKey: string|null,
|
||||
* workspaceId: int|null,
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* concernReason: string|null,
|
||||
* returnFilters: array<string, mixed>|null
|
||||
* }|null $state
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public function resolveStateWithTruth(
|
||||
Tenant $tenant,
|
||||
?array $state,
|
||||
?TenantBackupHealthAssessment $backupHealth = null,
|
||||
?array $recoveryEvidence = null,
|
||||
): ?PortfolioArrivalContext
|
||||
{
|
||||
if ($state === null || ! $this->matchesTenantScope($tenant, $state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildContext($tenant, $state);
|
||||
return $this->buildContext($tenant, $state, $backupHealth, $recoveryEvidence);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -149,10 +171,18 @@ private function matchesTenantScope(Tenant $tenant, array $state): bool
|
||||
* returnFilters: array<string, mixed>|null
|
||||
* } $state
|
||||
*/
|
||||
private function buildContext(Tenant $tenant, array $state): PortfolioArrivalContext
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
private function buildContext(
|
||||
Tenant $tenant,
|
||||
array $state,
|
||||
?TenantBackupHealthAssessment $backupHealth = null,
|
||||
?array $recoveryEvidence = null,
|
||||
): PortfolioArrivalContext
|
||||
{
|
||||
$backupHealth = $this->tenantBackupHealthResolver->assess($tenant);
|
||||
$recoveryEvidence = $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant);
|
||||
$backupHealth ??= $this->tenantBackupHealthResolver->assess($tenant);
|
||||
$recoveryEvidence ??= $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
return new PortfolioArrivalContext(
|
||||
sourceSurface: $state['sourceSurface'],
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceActions\Enums;
|
||||
|
||||
enum GovernanceFrictionClass: string
|
||||
{
|
||||
case F0 = 'F0';
|
||||
case F1 = 'F1';
|
||||
case F2 = 'F2';
|
||||
case F3 = 'F3';
|
||||
|
||||
public function requiresConfirmation(): bool
|
||||
{
|
||||
return $this !== self::F0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceActions\Enums;
|
||||
|
||||
enum GovernanceReasonPolicy: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Optional = 'optional';
|
||||
case Required = 'required';
|
||||
|
||||
public function requiresReason(): bool
|
||||
{
|
||||
return $this === self::Required;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,637 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceActions;
|
||||
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class GovernanceActionCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* familyKey: string,
|
||||
* canonicalObject: string,
|
||||
* panels: array<int, string>,
|
||||
* surfaceKeys: array<int, string>,
|
||||
* defaultActionOrder: array<int, string>,
|
||||
* supportsDocumentedDeviation: bool,
|
||||
* defaultMutationScopeSource: string
|
||||
* }>
|
||||
*/
|
||||
public static function families(): array
|
||||
{
|
||||
return [
|
||||
'exception_decision' => [
|
||||
'familyKey' => 'exception_decision',
|
||||
'canonicalObject' => 'exception',
|
||||
'panels' => ['admin', 'tenant'],
|
||||
'surfaceKeys' => ['finding_exceptions_queue', 'view_finding_exception', 'view_finding'],
|
||||
'defaultActionOrder' => [
|
||||
'approve_exception',
|
||||
'reject_exception',
|
||||
'renew_exception',
|
||||
'revoke_exception',
|
||||
],
|
||||
'supportsDocumentedDeviation' => true,
|
||||
'defaultMutationScopeSource' => 'exception governance',
|
||||
],
|
||||
'review_lifecycle' => [
|
||||
'familyKey' => 'review_lifecycle',
|
||||
'canonicalObject' => 'review',
|
||||
'panels' => ['tenant'],
|
||||
'surfaceKeys' => ['view_tenant_review'],
|
||||
'defaultActionOrder' => ['refresh_review', 'publish_review', 'archive_review'],
|
||||
'supportsDocumentedDeviation' => true,
|
||||
'defaultMutationScopeSource' => 'tenant review lifecycle',
|
||||
],
|
||||
'evidence_lifecycle' => [
|
||||
'familyKey' => 'evidence_lifecycle',
|
||||
'canonicalObject' => 'snapshot',
|
||||
'panels' => ['tenant'],
|
||||
'surfaceKeys' => ['list_evidence_snapshots', 'view_evidence_snapshot'],
|
||||
'defaultActionOrder' => ['refresh_evidence', 'expire_snapshot'],
|
||||
'supportsDocumentedDeviation' => true,
|
||||
'defaultMutationScopeSource' => 'evidence lifecycle',
|
||||
],
|
||||
'run_triage' => [
|
||||
'familyKey' => 'run_triage',
|
||||
'canonicalObject' => 'run',
|
||||
'panels' => ['system'],
|
||||
'surfaceKeys' => ['system_view_run'],
|
||||
'defaultActionOrder' => ['retry_run', 'mark_investigated', 'cancel_run'],
|
||||
'supportsDocumentedDeviation' => true,
|
||||
'defaultMutationScopeSource' => 'run triage',
|
||||
],
|
||||
'finding_lifecycle' => [
|
||||
'familyKey' => 'finding_lifecycle',
|
||||
'canonicalObject' => 'finding',
|
||||
'panels' => ['tenant'],
|
||||
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
'defaultActionOrder' => ['close_finding', 'reopen_finding'],
|
||||
'supportsDocumentedDeviation' => false,
|
||||
'defaultMutationScopeSource' => 'finding lifecycle',
|
||||
],
|
||||
'tenant_lifecycle' => [
|
||||
'familyKey' => 'tenant_lifecycle',
|
||||
'canonicalObject' => 'tenant',
|
||||
'panels' => ['admin'],
|
||||
'surfaceKeys' => ['tenant_index_row', 'view_tenant', 'edit_tenant'],
|
||||
'defaultActionOrder' => ['archive_tenant', 'restore_tenant'],
|
||||
'supportsDocumentedDeviation' => true,
|
||||
'defaultMutationScopeSource' => 'tenant lifecycle',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, GovernanceActionRule>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
'approve_exception' => new GovernanceActionRule(
|
||||
actionKey: 'approve_exception',
|
||||
familyKey: 'exception_decision',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Approve exception',
|
||||
modalHeading: 'Approve exception',
|
||||
modalDescription: 'Approve this exception request for the selected tenant and linked finding. TenantPilot updates the governed exception decision and risk-acceptance continuity only.',
|
||||
successTitle: 'Exception approved',
|
||||
auditVerb: 'approve exception',
|
||||
serviceOwner: 'FindingExceptionService',
|
||||
surfaceKeys: ['finding_exceptions_queue'],
|
||||
),
|
||||
'reject_exception' => new GovernanceActionRule(
|
||||
actionKey: 'reject_exception',
|
||||
familyKey: 'exception_decision',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'contextual',
|
||||
canonicalLabel: 'Reject exception',
|
||||
modalHeading: 'Reject exception',
|
||||
modalDescription: 'Reject this exception request for the selected tenant and linked finding. TenantPilot records the governance decision and leaves the finding out of governed risk acceptance.',
|
||||
successTitle: 'Exception rejected',
|
||||
auditVerb: 'reject exception',
|
||||
serviceOwner: 'FindingExceptionService',
|
||||
surfaceKeys: ['finding_exceptions_queue'],
|
||||
),
|
||||
'renew_exception' => new GovernanceActionRule(
|
||||
actionKey: 'renew_exception',
|
||||
familyKey: 'exception_decision',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Renew exception',
|
||||
modalHeading: 'Renew exception',
|
||||
modalDescription: 'Submit a renewal request for this governed exception. TenantPilot records the request and keeps the formal decision pending until it is reviewed.',
|
||||
successTitle: 'Renewal request submitted',
|
||||
auditVerb: 'renew exception',
|
||||
serviceOwner: 'FindingExceptionService',
|
||||
surfaceKeys: ['view_finding_exception', 'view_finding'],
|
||||
),
|
||||
'revoke_exception' => new GovernanceActionRule(
|
||||
actionKey: 'revoke_exception',
|
||||
familyKey: 'exception_decision',
|
||||
frictionClass: GovernanceFrictionClass::F3,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'required',
|
||||
canonicalLabel: 'Revoke exception',
|
||||
modalHeading: 'Revoke exception',
|
||||
modalDescription: 'Revoke this active exception for the tenant and linked finding. TenantPilot records the revocation and removes the governed exception support.',
|
||||
successTitle: 'Exception revoked',
|
||||
auditVerb: 'revoke exception',
|
||||
serviceOwner: 'FindingExceptionService',
|
||||
surfaceKeys: ['view_finding_exception', 'view_finding'],
|
||||
),
|
||||
'refresh_review' => new GovernanceActionRule(
|
||||
actionKey: 'refresh_review',
|
||||
familyKey: 'review_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F1,
|
||||
reasonPolicy: GovernanceReasonPolicy::None,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Refresh review',
|
||||
modalHeading: 'Refresh review',
|
||||
modalDescription: 'Refresh this tenant review from the latest eligible evidence basis. TenantPilot queues a recomputation for this review and keeps existing publication history untouched.',
|
||||
successTitle: 'Refresh review queued',
|
||||
auditVerb: 'refresh review',
|
||||
serviceOwner: 'TenantReviewService',
|
||||
surfaceKeys: ['view_tenant_review'],
|
||||
),
|
||||
'publish_review' => new GovernanceActionRule(
|
||||
actionKey: 'publish_review',
|
||||
familyKey: 'review_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Publish review',
|
||||
modalHeading: 'Publish review',
|
||||
modalDescription: 'Publish this tenant review as the current governed review outcome for this tenant. TenantPilot records the publication decision only.',
|
||||
successTitle: 'Review published',
|
||||
auditVerb: 'publish review',
|
||||
serviceOwner: 'TenantReviewLifecycleService',
|
||||
surfaceKeys: ['view_tenant_review'],
|
||||
),
|
||||
'archive_review' => new GovernanceActionRule(
|
||||
actionKey: 'archive_review',
|
||||
familyKey: 'review_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F3,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'required',
|
||||
canonicalLabel: 'Archive review',
|
||||
modalHeading: 'Archive review',
|
||||
modalDescription: 'Archive this tenant review so it stays historical only. TenantPilot preserves the evidence history but removes the review from active lifecycle work.',
|
||||
successTitle: 'Review archived',
|
||||
auditVerb: 'archive review',
|
||||
serviceOwner: 'TenantReviewLifecycleService',
|
||||
surfaceKeys: ['view_tenant_review'],
|
||||
),
|
||||
'refresh_evidence' => new GovernanceActionRule(
|
||||
actionKey: 'refresh_evidence',
|
||||
familyKey: 'evidence_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F1,
|
||||
reasonPolicy: GovernanceReasonPolicy::None,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Refresh evidence',
|
||||
modalHeading: 'Refresh evidence',
|
||||
modalDescription: 'Refresh content evidence for this tenant. TenantPilot queues a new snapshot and leaves existing governed snapshots intact.',
|
||||
successTitle: 'Refresh evidence queued',
|
||||
auditVerb: 'refresh evidence',
|
||||
serviceOwner: 'EvidenceSnapshotService',
|
||||
surfaceKeys: ['view_evidence_snapshot'],
|
||||
),
|
||||
'expire_snapshot' => new GovernanceActionRule(
|
||||
actionKey: 'expire_snapshot',
|
||||
familyKey: 'evidence_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'required',
|
||||
canonicalLabel: 'Expire snapshot',
|
||||
modalHeading: 'Expire snapshot',
|
||||
modalDescription: 'Expire this evidence snapshot for the current tenant. TenantPilot records that the snapshot is no longer valid for governance use.',
|
||||
successTitle: 'Snapshot expired',
|
||||
auditVerb: 'expire snapshot',
|
||||
serviceOwner: 'EvidenceSnapshotService',
|
||||
surfaceKeys: ['list_evidence_snapshots', 'view_evidence_snapshot'],
|
||||
),
|
||||
'retry_run' => new GovernanceActionRule(
|
||||
actionKey: 'retry_run',
|
||||
familyKey: 'run_triage',
|
||||
frictionClass: GovernanceFrictionClass::F1,
|
||||
reasonPolicy: GovernanceReasonPolicy::None,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Retry',
|
||||
modalHeading: 'Retry run',
|
||||
modalDescription: 'Retry this failed run. TenantPilot queues a new run and preserves the original run history.',
|
||||
successTitle: 'Retry queued',
|
||||
auditVerb: 'retry run',
|
||||
serviceOwner: 'OperationRunTriageService',
|
||||
surfaceKeys: ['system_view_run'],
|
||||
),
|
||||
'mark_investigated' => new GovernanceActionRule(
|
||||
actionKey: 'mark_investigated',
|
||||
familyKey: 'run_triage',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Mark investigated',
|
||||
modalHeading: 'Mark investigated',
|
||||
modalDescription: 'Mark this run as investigated. TenantPilot records the triage rationale on this run only.',
|
||||
successTitle: 'Run marked as investigated',
|
||||
auditVerb: 'mark investigated',
|
||||
serviceOwner: 'OperationRunTriageService',
|
||||
surfaceKeys: ['system_view_run'],
|
||||
),
|
||||
'cancel_run' => new GovernanceActionRule(
|
||||
actionKey: 'cancel_run',
|
||||
familyKey: 'run_triage',
|
||||
frictionClass: GovernanceFrictionClass::F3,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'required',
|
||||
canonicalLabel: 'Cancel',
|
||||
modalHeading: 'Cancel run',
|
||||
modalDescription: 'Cancel this in-flight run. TenantPilot records the cancellation reason and marks the run as failed.',
|
||||
successTitle: 'Run cancelled',
|
||||
auditVerb: 'cancel run',
|
||||
serviceOwner: 'OperationRunTriageService',
|
||||
surfaceKeys: ['system_view_run'],
|
||||
),
|
||||
'close_finding' => new GovernanceActionRule(
|
||||
actionKey: 'close_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Close',
|
||||
modalHeading: 'Close finding',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.',
|
||||
successTitle: 'Finding closed',
|
||||
auditVerb: 'close finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
),
|
||||
'reopen_finding' => new GovernanceActionRule(
|
||||
actionKey: 'reopen_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Reopen',
|
||||
modalHeading: 'Reopen finding',
|
||||
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.',
|
||||
successTitle: 'Finding reopened',
|
||||
auditVerb: 'reopen finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
),
|
||||
'archive_tenant' => new GovernanceActionRule(
|
||||
actionKey: 'archive_tenant',
|
||||
familyKey: 'tenant_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F3,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'required',
|
||||
canonicalLabel: 'Archive',
|
||||
modalHeading: 'Archive tenant',
|
||||
modalDescription: 'Archive this tenant. TenantPilot keeps it available for inspection and audit history but removes it from active management flows.',
|
||||
successTitle: 'Tenant archived',
|
||||
auditVerb: 'archive tenant',
|
||||
serviceOwner: 'TenantResource',
|
||||
surfaceKeys: ['tenant_index_row', 'view_tenant', 'edit_tenant'],
|
||||
),
|
||||
'restore_tenant' => new GovernanceActionRule(
|
||||
actionKey: 'restore_tenant',
|
||||
familyKey: 'tenant_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F1,
|
||||
reasonPolicy: GovernanceReasonPolicy::None,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Restore',
|
||||
modalHeading: 'Restore tenant',
|
||||
modalDescription: 'Restore this tenant so it becomes available again in normal management flows.',
|
||||
successTitle: 'Tenant restored',
|
||||
auditVerb: 'restore tenant',
|
||||
serviceOwner: 'TenantResource',
|
||||
surfaceKeys: ['tenant_index_row', 'view_tenant', 'edit_tenant'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public static function rule(string $actionKey): GovernanceActionRule
|
||||
{
|
||||
$rule = static::rules()[$actionKey] ?? null;
|
||||
|
||||
if (! $rule instanceof GovernanceActionRule) {
|
||||
throw new InvalidArgumentException(sprintf('Unknown governance action "%s".', $actionKey));
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* surfaceKey: string,
|
||||
* pageClass: string,
|
||||
* actionName: string,
|
||||
* familyKey: string,
|
||||
* statePredicate: string,
|
||||
* primaryOrSecondary: string,
|
||||
* capabilityKey: string|null,
|
||||
* uiFieldKey: string|null,
|
||||
* auditChannel: string
|
||||
* }>
|
||||
*/
|
||||
public static function surfaceBindings(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'pageClass' => 'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
|
||||
'actionName' => 'approve_selected_exception',
|
||||
'familyKey' => 'exception_decision',
|
||||
'statePredicate' => 'selected exception is pending',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'finding_exception.approve',
|
||||
'uiFieldKey' => 'approval_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'pageClass' => 'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
|
||||
'actionName' => 'reject_selected_exception',
|
||||
'familyKey' => 'exception_decision',
|
||||
'statePredicate' => 'selected exception is pending',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'finding_exception.approve',
|
||||
'uiFieldKey' => 'rejection_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding_exception',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
|
||||
'actionName' => 'renew_exception',
|
||||
'familyKey' => 'exception_decision',
|
||||
'statePredicate' => 'exception can be renewed',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'finding_exception.manage',
|
||||
'uiFieldKey' => 'request_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding_exception',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
|
||||
'actionName' => 'revoke_exception',
|
||||
'familyKey' => 'exception_decision',
|
||||
'statePredicate' => 'exception can be revoked',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'finding_exception.manage',
|
||||
'uiFieldKey' => 'revocation_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_evidence_snapshot',
|
||||
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
|
||||
'actionName' => 'refresh_evidence',
|
||||
'familyKey' => 'evidence_lifecycle',
|
||||
'statePredicate' => 'snapshot is visible to tenant operator',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'evidence.manage',
|
||||
'uiFieldKey' => null,
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_evidence_snapshot',
|
||||
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
|
||||
'actionName' => 'expire_snapshot',
|
||||
'familyKey' => 'evidence_lifecycle',
|
||||
'statePredicate' => 'snapshot can expire',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'evidence.manage',
|
||||
'uiFieldKey' => 'expiration_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'list_evidence_snapshots',
|
||||
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource',
|
||||
'actionName' => 'expire',
|
||||
'familyKey' => 'evidence_lifecycle',
|
||||
'statePredicate' => 'snapshot can expire',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'evidence.manage',
|
||||
'uiFieldKey' => 'expiration_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_tenant_review',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
|
||||
'actionName' => 'refresh_review',
|
||||
'familyKey' => 'review_lifecycle',
|
||||
'statePredicate' => 'review is mutable',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'tenant_review.manage',
|
||||
'uiFieldKey' => null,
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_tenant_review',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
|
||||
'actionName' => 'publish_review',
|
||||
'familyKey' => 'review_lifecycle',
|
||||
'statePredicate' => 'review is mutable and ready to publish',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'tenant_review.manage',
|
||||
'uiFieldKey' => 'publish_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_tenant_review',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
|
||||
'actionName' => 'archive_review',
|
||||
'familyKey' => 'review_lifecycle',
|
||||
'statePredicate' => 'review is not terminal',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant_review.manage',
|
||||
'uiFieldKey' => 'archive_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'system_view_run',
|
||||
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
|
||||
'actionName' => 'retry',
|
||||
'familyKey' => 'run_triage',
|
||||
'statePredicate' => 'run is retryable',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'platform.operations.manage',
|
||||
'uiFieldKey' => null,
|
||||
'auditChannel' => 'system_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'system_view_run',
|
||||
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
|
||||
'actionName' => 'mark_investigated',
|
||||
'familyKey' => 'run_triage',
|
||||
'statePredicate' => 'run is triage-owned',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'platform.operations.manage',
|
||||
'uiFieldKey' => 'reason',
|
||||
'auditChannel' => 'system_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'system_view_run',
|
||||
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
|
||||
'actionName' => 'cancel',
|
||||
'familyKey' => 'run_triage',
|
||||
'statePredicate' => 'run is cancellable',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'platform.operations.manage',
|
||||
'uiFieldKey' => 'reason',
|
||||
'auditChannel' => 'system_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
'actionName' => 'close',
|
||||
'familyKey' => 'finding_lifecycle',
|
||||
'statePredicate' => 'finding has open status',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant_findings.close',
|
||||
'uiFieldKey' => 'closed_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
'actionName' => 'reopen',
|
||||
'familyKey' => 'finding_lifecycle',
|
||||
'statePredicate' => 'finding has terminal status',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant_findings.triage',
|
||||
'uiFieldKey' => 'reopen_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_tenant',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
|
||||
'actionName' => 'archive',
|
||||
'familyKey' => 'tenant_lifecycle',
|
||||
'statePredicate' => 'tenant is active',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant.delete',
|
||||
'uiFieldKey' => 'archive_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_tenant',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
|
||||
'actionName' => 'restore',
|
||||
'familyKey' => 'tenant_lifecycle',
|
||||
'statePredicate' => 'tenant is archived',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant.delete',
|
||||
'uiFieldKey' => null,
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'edit_tenant',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
|
||||
'actionName' => 'archive',
|
||||
'familyKey' => 'tenant_lifecycle',
|
||||
'statePredicate' => 'tenant is active',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant.delete',
|
||||
'uiFieldKey' => 'archive_reason',
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'edit_tenant',
|
||||
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
|
||||
'actionName' => 'restore',
|
||||
'familyKey' => 'tenant_lifecycle',
|
||||
'statePredicate' => 'tenant is archived',
|
||||
'primaryOrSecondary' => 'secondary',
|
||||
'capabilityKey' => 'tenant.delete',
|
||||
'uiFieldKey' => null,
|
||||
'auditChannel' => 'workspace_audit',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* actionKey: string,
|
||||
* surfaceKey: string,
|
||||
* deviationType: string,
|
||||
* rationale: string,
|
||||
* reviewGate: string,
|
||||
* allowedUntil: string|null
|
||||
* }>
|
||||
*/
|
||||
public static function documentedDeviations(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'actionKey' => 'reject_exception',
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'deviationType' => 'danger_override',
|
||||
'rationale' => 'Reject stays visually distinct from approval without escalating into the F3 destructive family.',
|
||||
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
|
||||
'allowedUntil' => null,
|
||||
],
|
||||
[
|
||||
'actionKey' => 'refresh_evidence',
|
||||
'surfaceKey' => 'view_evidence_snapshot',
|
||||
'deviationType' => 'reason_override',
|
||||
'rationale' => 'Refresh evidence remains an F1 action with no operator-entered rationale in the current release.',
|
||||
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
|
||||
'allowedUntil' => null,
|
||||
],
|
||||
[
|
||||
'actionKey' => 'retry_run',
|
||||
'surfaceKey' => 'system_view_run',
|
||||
'deviationType' => 'reason_override',
|
||||
'rationale' => 'Retry stays queue-first and does not collect free-text rationale unless a documented future case requires it.',
|
||||
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
|
||||
'allowedUntil' => null,
|
||||
],
|
||||
[
|
||||
'actionKey' => 'restore_tenant',
|
||||
'surfaceKey' => 'view_tenant',
|
||||
'deviationType' => 'reason_override',
|
||||
'rationale' => 'Restore remains a confirmed F1 lifecycle action with no required rationale in the current release.',
|
||||
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
|
||||
'allowedUntil' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* surfaceKey: string,
|
||||
* pageClass: string,
|
||||
* actionName: string,
|
||||
* familyKey: string,
|
||||
* statePredicate: string,
|
||||
* primaryOrSecondary: string,
|
||||
* capabilityKey: string|null,
|
||||
* uiFieldKey: string|null,
|
||||
* auditChannel: string
|
||||
* }>
|
||||
*/
|
||||
public static function bindingsForSurface(string $surfaceKey): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
static::surfaceBindings(),
|
||||
static fn (array $binding): bool => $binding['surfaceKey'] === $surfaceKey,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function actionKeys(): array
|
||||
{
|
||||
return array_keys(static::rules());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceActions;
|
||||
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
|
||||
|
||||
final readonly class GovernanceActionRule
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $surfaceKeys
|
||||
*/
|
||||
public function __construct(
|
||||
public string $actionKey,
|
||||
public string $familyKey,
|
||||
public GovernanceFrictionClass $frictionClass,
|
||||
public GovernanceReasonPolicy $reasonPolicy,
|
||||
public string $dangerPolicy,
|
||||
public string $canonicalLabel,
|
||||
public string $modalHeading,
|
||||
public string $modalDescription,
|
||||
public string $successTitle,
|
||||
public string $auditVerb,
|
||||
public string $serviceOwner,
|
||||
public array $surfaceKeys = [],
|
||||
) {}
|
||||
|
||||
public function requiresConfirmation(): bool
|
||||
{
|
||||
return $this->frictionClass->requiresConfirmation();
|
||||
}
|
||||
|
||||
public function requiresReason(): bool
|
||||
{
|
||||
return $this->reasonPolicy->requiresReason();
|
||||
}
|
||||
|
||||
public function requiresDangerSeparation(): bool
|
||||
{
|
||||
return $this->dangerPolicy === 'required';
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
@ -11,8 +14,14 @@
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->prepend(SuppressDebugbarForSmokeRequests::class);
|
||||
|
||||
$middleware->encryptCookies(except: [
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||
]);
|
||||
|
||||
$middleware->web(prepend: [
|
||||
\App\Http\Middleware\UseSystemSessionCookieForLivewireRequests::class,
|
||||
UseSystemSessionCookieForLivewireRequests::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
|
||||
@ -10,12 +10,14 @@
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
@ -65,7 +67,213 @@
|
||||
->middleware('throttle:entra-callback')
|
||||
->name('auth.entra.callback');
|
||||
|
||||
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) {
|
||||
$makeSmokeCookie = static fn () => cookie()->make(
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
120,
|
||||
);
|
||||
|
||||
$resolveSmokeTenant = static function (?string $identifier): ?Tenant {
|
||||
$identifier = trim((string) $identifier);
|
||||
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->where(function ($query) use ($identifier): void {
|
||||
$query->where('external_id', $identifier)
|
||||
->orWhere('tenant_id', $identifier);
|
||||
|
||||
if (ctype_digit($identifier)) {
|
||||
$query->orWhereKey((int) $identifier);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
};
|
||||
|
||||
$resolveSmokeWorkspace = static function (?string $identifier, ?Tenant $tenant = null): ?Workspace {
|
||||
if ($tenant instanceof Tenant) {
|
||||
return Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||
}
|
||||
|
||||
$identifier = trim((string) $identifier);
|
||||
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()
|
||||
->where(function ($query) use ($identifier): void {
|
||||
$query->where('slug', $identifier);
|
||||
|
||||
if (ctype_digit($identifier)) {
|
||||
$query->orWhereKey((int) $identifier);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
};
|
||||
|
||||
$resolveSmokeRedirect = static function (?string $redirect, ?Tenant $tenant = null): string {
|
||||
$fallback = $tenant instanceof Tenant && ! $tenant->trashed()
|
||||
? '/admin/t/'.$tenant->external_id
|
||||
: '/admin';
|
||||
|
||||
$redirect = trim((string) $redirect);
|
||||
|
||||
if ($redirect === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$parsedRedirect = parse_url($redirect);
|
||||
|
||||
if ($parsedRedirect === false || isset($parsedRedirect['scheme']) || isset($parsedRedirect['host'])) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$path = '/'.ltrim((string) ($parsedRedirect['path'] ?? ''), '/');
|
||||
|
||||
if ($path !== '/admin' && ! str_starts_with($path, '/admin/')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$query = isset($parsedRedirect['query']) ? '?'.$parsedRedirect['query'] : '';
|
||||
$fragment = isset($parsedRedirect['fragment']) ? '#'.$parsedRedirect['fragment'] : '';
|
||||
|
||||
return $path.$query.$fragment;
|
||||
};
|
||||
|
||||
$resolveSmokeUser = static function (?string $email, ?Workspace $workspace = null, ?Tenant $tenant = null): ?User {
|
||||
$email = trim((string) $email);
|
||||
|
||||
if ($email !== '') {
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
|
||||
$scopedWorkspace = $workspace;
|
||||
|
||||
if (! $scopedWorkspace instanceof Workspace && $tenant instanceof Tenant) {
|
||||
$scopedWorkspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||
}
|
||||
|
||||
if (! $scopedWorkspace instanceof Workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rolePriority = [
|
||||
WorkspaceRole::Owner->value => 0,
|
||||
WorkspaceRole::Manager->value => 1,
|
||||
WorkspaceRole::Operator->value => 2,
|
||||
WorkspaceRole::Readonly->value => 3,
|
||||
];
|
||||
|
||||
$users = User::query()
|
||||
->whereHas('workspaceMemberships', function ($query) use ($scopedWorkspace): void {
|
||||
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
|
||||
})
|
||||
->when($tenant instanceof Tenant, function ($query) use ($tenant): void {
|
||||
$query->whereHas('tenantMemberships', function ($membershipQuery) use ($tenant): void {
|
||||
$membershipQuery->where('tenant_id', (int) $tenant->getKey());
|
||||
});
|
||||
})
|
||||
->with(['workspaceMemberships' => function ($query) use ($scopedWorkspace): void {
|
||||
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
|
||||
}])
|
||||
->get()
|
||||
->filter(function (User $user) use ($tenant): bool {
|
||||
return ! $tenant instanceof Tenant || $user->canAccessTenant($tenant);
|
||||
})
|
||||
->sortBy(function (User $user) use ($rolePriority): array {
|
||||
$role = $user->workspaceMemberships->first()?->role;
|
||||
|
||||
return [
|
||||
$rolePriority[(string) $role] ?? 99,
|
||||
(int) $user->getKey(),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
$user = $users->first();
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
};
|
||||
|
||||
$completeSmokeLogin = static function (
|
||||
Request $request,
|
||||
?string $email = null,
|
||||
?string $tenantIdentifier = null,
|
||||
?string $workspaceIdentifier = null,
|
||||
?string $redirect = null,
|
||||
) use (
|
||||
$makeSmokeCookie,
|
||||
$resolveSmokeRedirect,
|
||||
$resolveSmokeTenant,
|
||||
$resolveSmokeUser,
|
||||
$resolveSmokeWorkspace,
|
||||
): \Illuminate\Http\RedirectResponse {
|
||||
$tenant = $resolveSmokeTenant($tenantIdentifier);
|
||||
$workspace = $resolveSmokeWorkspace($workspaceIdentifier, $tenant);
|
||||
$user = $resolveSmokeUser($email, $workspace, $tenant);
|
||||
|
||||
abort_unless($user instanceof User, 404);
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
$workspace = $workspaceContext->resolveInitialWorkspaceFor($user, $request);
|
||||
}
|
||||
|
||||
abort_unless($workspace instanceof Workspace, 404);
|
||||
abort_unless($workspaceContext->isMember($user, $workspace), 404);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
abort_unless((int) $tenant->workspace_id === (int) $workspace->getKey(), 404);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
}
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->put(
|
||||
SuppressDebugbarForSmokeRequests::SESSION_KEY,
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
);
|
||||
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspaceContext->rememberTenantContext($tenant, $request);
|
||||
} else {
|
||||
$workspaceContext->clearRememberedTenantContext($request);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->to($resolveSmokeRedirect($redirect, $tenant))
|
||||
->withCookie($makeSmokeCookie());
|
||||
};
|
||||
|
||||
Route::get('/admin/local/smoke-login', function (Request $request) use ($completeSmokeLogin) {
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||
$defaultEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
|
||||
$defaultTenant = is_array($fixture)
|
||||
? (data_get($fixture, 'blocked_drillthrough.tenant_external_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_id'))
|
||||
: null;
|
||||
$defaultWorkspace = is_array($fixture) ? data_get($fixture, 'workspace.slug') : null;
|
||||
|
||||
return $completeSmokeLogin(
|
||||
$request,
|
||||
email: (string) ($request->query('email', $defaultEmail ?? '')),
|
||||
tenantIdentifier: (string) ($request->query('tenant', $defaultTenant ?? '')),
|
||||
workspaceIdentifier: (string) ($request->query('workspace', $defaultWorkspace ?? '')),
|
||||
redirect: (string) ($request->query('redirect', '')),
|
||||
);
|
||||
})->name('admin.local.smoke-login');
|
||||
|
||||
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) use ($completeSmokeLogin) {
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||
@ -77,18 +285,12 @@
|
||||
abort_unless(is_string($userEmail) && $userEmail !== '', 404);
|
||||
abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404);
|
||||
|
||||
$user = User::query()->where('email', $userEmail)->firstOrFail();
|
||||
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->firstOrFail();
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||
$workspaceContext->rememberTenantContext($tenant, $request);
|
||||
|
||||
return redirect()->to('/admin/t/'.$tenant->external_id);
|
||||
return $completeSmokeLogin(
|
||||
$request,
|
||||
email: $userEmail,
|
||||
tenantIdentifier: $tenantRouteKey,
|
||||
workspaceIdentifier: is_array($fixture) ? data_get($fixture, 'workspace.slug') : null,
|
||||
);
|
||||
})->name('admin.local.backup-health-browser-fixture-login');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||
|
||||
@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec194ApprovedFindingException(Tenant $tenant, User $requester): FindingException
|
||||
{
|
||||
$approver = User::factory()->create();
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $approver,
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
]);
|
||||
|
||||
/** @var FindingExceptionService $service */
|
||||
$service = app(FindingExceptionService::class);
|
||||
|
||||
$requested = $service->request($finding, $tenant, $requester, [
|
||||
'owner_user_id' => (int) $requester->getKey(),
|
||||
'request_reason' => 'Spec194 browser smoke request.',
|
||||
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
return $service->approve($requested, $approver, [
|
||||
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
'approval_reason' => 'Spec194 browser smoke approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
function spec194SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
|
||||
{
|
||||
return route('admin.local.smoke-login', array_filter([
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
'redirect' => $redirect,
|
||||
], static fn (?string $value): bool => filled($value)));
|
||||
}
|
||||
|
||||
it('smokes tenant and admin governance semantics through modal entry points', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$pendingException = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Spec194 focused review queue smoke.',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$approvedException = spec194ApprovedFindingException($tenant, $user);
|
||||
|
||||
$snapshotRun = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $snapshotRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Ready->value,
|
||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => [],
|
||||
'section_state_counts' => [
|
||||
'complete' => 6,
|
||||
'partial' => 0,
|
||||
'missing' => 0,
|
||||
'stale' => 0,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$review = $review->refresh();
|
||||
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec194 Archived Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $archivedTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
visit(spec194SmokeLoginUrl($user, $tenant))
|
||||
->waitForText('Dashboard')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(FindingExceptionsQueue::getUrl(panel: 'admin').'?exception='.(int) $pendingException->getKey())
|
||||
->waitForText('Focused review lane')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception');
|
||||
|
||||
visit(FindingExceptionResource::getUrl('view', ['record' => $approvedException], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Renew exception')
|
||||
->assertSee('Revoke exception');
|
||||
|
||||
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('Publish review')
|
||||
->waitForText('Publication reason')
|
||||
->click('Cancel')
|
||||
->click('[aria-label="More"]')
|
||||
->assertSee('Refresh review')
|
||||
->assertSee('Export executive pack')
|
||||
->click('[aria-label="Danger"]')
|
||||
->click('Archive review')
|
||||
->waitForText('Archive reason')
|
||||
->click('Cancel')
|
||||
->assertSee('Publish review')
|
||||
->assertSee('Evidence snapshot');
|
||||
|
||||
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('Refresh evidence')
|
||||
->waitForText('Confirm')
|
||||
->click('Cancel')
|
||||
->click('Expire snapshot')
|
||||
->waitForText('Expiry reason')
|
||||
->click('Cancel')
|
||||
->assertSee('Refresh evidence')
|
||||
->assertSee('Expire snapshot');
|
||||
|
||||
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('[aria-label="Lifecycle"]')
|
||||
->click('Archive')
|
||||
->waitForText('Archive reason')
|
||||
->click('Cancel')
|
||||
->assertSee('Lifecycle');
|
||||
|
||||
visit(TenantResource::getUrl('edit', ['record' => $tenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Lifecycle');
|
||||
|
||||
visit(TenantResource::getUrl('view', ['record' => $archivedTenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Lifecycle');
|
||||
|
||||
visit(TenantResource::getUrl('edit', ['record' => $archivedTenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Lifecycle');
|
||||
});
|
||||
|
||||
it('smokes system run triage semantics without javascript errors', function (): void {
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$runningRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'type' => 'inventory_sync',
|
||||
'created_at' => now()->subMinutes(15),
|
||||
'started_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
auth('web')->logout();
|
||||
$this->flushSession();
|
||||
$this->actingAs($platformUser, 'platform');
|
||||
|
||||
visit(SystemOperationRunLinks::view($failedRun))
|
||||
->waitForText('Operation #'.(int) $failedRun->getKey())
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Retry')
|
||||
->assertSee('Mark investigated')
|
||||
->assertDontSee('Cancel');
|
||||
|
||||
visit(SystemOperationRunLinks::view($runningRun))
|
||||
->waitForText('Operation #'.(int) $runningRun->getKey())
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Mark investigated')
|
||||
->assertSee('Cancel');
|
||||
});
|
||||
@ -23,6 +23,9 @@
|
||||
return $action->getLabel() === 'Archive' && $action->isConfirmationRequired();
|
||||
})
|
||||
->mountAction('archive')
|
||||
->setActionData([
|
||||
'archive_reason' => 'Retiring this tenant from active management.',
|
||||
])
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
@ -35,6 +38,15 @@
|
||||
->where('action', AuditActionId::TenantArchived->value)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$archiveAudit = AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantArchived->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect(data_get($archiveAudit?->metadata, 'reason'))->toBe('Retiring this tenant from active management.');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||
use Barryvdh\Debugbar\LaravelDebugbar;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('logs into the admin smoke helper with explicit tenant and workspace context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$response = $this->get(route('admin.local.smoke-login', [
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertPlainCookie(
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
|
||||
expect(session(App\Support\Workspaces\WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
|
||||
->and(session(SuppressDebugbarForSmokeRequests::SESSION_KEY))
|
||||
->toBe(SuppressDebugbarForSmokeRequests::COOKIE_VALUE)
|
||||
->and(data_get(session(App\Support\Workspaces\WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY), (string) $tenant->workspace_id))
|
||||
->toBe((int) $tenant->getKey());
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('suppresses debugbar only for smoke-cookie requests and restores normal state afterward', function (): void {
|
||||
config(['debugbar.enabled' => true]);
|
||||
|
||||
Route::middleware('web')->get('/__tests/smoke-debugbar-state', function () {
|
||||
$debugbarState = null;
|
||||
|
||||
if (app()->bound('debugbar')) {
|
||||
$debugbar = app('debugbar');
|
||||
|
||||
if ($debugbar instanceof LaravelDebugbar) {
|
||||
$debugbarState = $debugbar->isEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'config_enabled' => (bool) config('debugbar.enabled'),
|
||||
'service_enabled' => $debugbarState,
|
||||
]);
|
||||
});
|
||||
|
||||
$smokeResponse = $this->withUnencryptedCookies([
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME => SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
])->get('/__tests/smoke-debugbar-state');
|
||||
|
||||
$smokeResponse
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('config_enabled', false);
|
||||
|
||||
if ($smokeResponse->json('service_enabled') !== null) {
|
||||
expect($smokeResponse->json('service_enabled'))->toBeFalse();
|
||||
}
|
||||
|
||||
config(['debugbar.enabled' => true]);
|
||||
|
||||
if (app()->bound('debugbar')) {
|
||||
$debugbar = app('debugbar');
|
||||
|
||||
if ($debugbar instanceof LaravelDebugbar) {
|
||||
$debugbar->enable();
|
||||
}
|
||||
}
|
||||
|
||||
$normalMiddlewareState = null;
|
||||
$middleware = app(SuppressDebugbarForSmokeRequests::class);
|
||||
|
||||
$middleware->handle(Request::create('/admin/operations', 'GET'), function () use (&$normalMiddlewareState) {
|
||||
$normalMiddlewareState = config('debugbar.enabled');
|
||||
|
||||
return response('ok');
|
||||
});
|
||||
|
||||
expect($normalMiddlewareState)->toBeTrue();
|
||||
});
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -27,7 +28,11 @@
|
||||
expect($tenant)->not->toBeNull();
|
||||
|
||||
$this->get(route('admin.local.backup-health-browser-fixture-login'))
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertPlainCookie(
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
|
||||
|
||||
@ -20,8 +20,14 @@
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
]);
|
||||
|
||||
app(App\Services\Evidence\EvidenceSnapshotService::class)->expire($snapshot, $user);
|
||||
app(App\Services\Evidence\EvidenceSnapshotService::class)->expire($snapshot, $user, 'Evidence basis is obsolete.');
|
||||
|
||||
$expiredAudit = AuditLog::query()
|
||||
->where('action', AuditActionId::EvidenceSnapshotExpired->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotCreated->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue();
|
||||
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue()
|
||||
->and(data_get($expiredAudit?->metadata, 'reason'))->toBe('Evidence basis is obsolete.');
|
||||
});
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -166,17 +168,41 @@ function evidenceSnapshotHeaderActions(Testable $component): array
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
$refreshComponent = Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('refresh_snapshot')
|
||||
->assertActionVisible('expire_snapshot');
|
||||
->assertActionVisible('refresh_evidence')
|
||||
->assertActionExists('refresh_evidence', fn (Action $action): bool => $action->getLabel() === $refreshRule->canonicalLabel
|
||||
&& $action->isConfirmationRequired()
|
||||
&& $action->getModalHeading() === $refreshRule->modalHeading
|
||||
&& $action->getModalDescription() === $refreshRule->modalDescription)
|
||||
->assertActionVisible('expire_snapshot')
|
||||
->assertActionExists('expire_snapshot', fn (Action $action): bool => $action->getLabel() === $expireRule->canonicalLabel
|
||||
&& $action->isConfirmationRequired()
|
||||
&& $action->getModalHeading() === $expireRule->modalHeading
|
||||
&& $action->getModalDescription() === $expireRule->modalDescription)
|
||||
->mountAction('refresh_evidence')
|
||||
->assertActionMounted('refresh_evidence');
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('expire_snapshot')
|
||||
->mountAction('expire_snapshot')
|
||||
->assertActionMounted('expire_snapshot')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['expiration_reason']);
|
||||
|
||||
expect(collect(evidenceSnapshotHeaderActions($component))
|
||||
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all())
|
||||
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
|
||||
->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
|
||||
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
|
||||
->toContain('operation_run', 'review_pack');
|
||||
});
|
||||
@ -386,8 +412,8 @@ function evidenceSnapshotHeaderActions(Testable $component): array
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('refresh_snapshot')
|
||||
->assertActionDisabled('refresh_snapshot')
|
||||
->assertActionVisible('refresh_evidence')
|
||||
->assertActionDisabled('refresh_evidence')
|
||||
->assertActionVisible('expire_snapshot')
|
||||
->assertActionDisabled('expire_snapshot');
|
||||
});
|
||||
|
||||
@ -48,7 +48,10 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->callTableAction('expire', $snapshot);
|
||||
->callTableAction('expire', $snapshot, [
|
||||
'expiration_reason' => 'This snapshot is no longer valid for governance use.',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$snapshot->refresh();
|
||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||
|
||||
@ -409,7 +409,9 @@
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListTenants::class)
|
||||
->callTableAction('archive', $tenant);
|
||||
->callTableAction('archive', $tenant, [
|
||||
'archive_reason' => 'Removing this tenant from the active housekeeping list.',
|
||||
]);
|
||||
|
||||
expect(Tenant::count())->toBe(0);
|
||||
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'consent_status' => 'required',
|
||||
'is_enabled' => true,
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
@ -55,6 +57,13 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewProviderConnection::class, ['record' => $connection->getKey()])
|
||||
->assertActionVisible('check_connection')
|
||||
->assertActionDisabled('check_connection')
|
||||
->assertActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission())
|
||||
->assertActionVisible('inventory_sync')
|
||||
->assertActionDisabled('inventory_sync')
|
||||
->assertActionVisible('compliance_snapshot')
|
||||
->assertActionDisabled('compliance_snapshot')
|
||||
->assertActionVisible('edit')
|
||||
->assertActionDisabled('edit')
|
||||
->assertActionExists('edit', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
@ -67,6 +76,8 @@
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'consent_status' => 'required',
|
||||
'is_enabled' => true,
|
||||
'provider' => 'microsoft',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
@ -79,6 +90,12 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewProviderConnection::class, ['record' => $connection->getKey()])
|
||||
->assertActionVisible('check_connection')
|
||||
->assertActionEnabled('check_connection')
|
||||
->assertActionVisible('inventory_sync')
|
||||
->assertActionEnabled('inventory_sync')
|
||||
->assertActionVisible('compliance_snapshot')
|
||||
->assertActionEnabled('compliance_snapshot')
|
||||
->assertActionVisible('edit')
|
||||
->assertActionEnabled('edit');
|
||||
});
|
||||
|
||||
@ -51,8 +51,6 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
||||
|
||||
$table = spec125CriticalTable($component);
|
||||
|
||||
expect($table->getDefaultSortColumn())->toBe('name');
|
||||
expect($table->getDefaultSortDirection())->toBe('asc');
|
||||
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
|
||||
expect($table->persistsSearchInSession())->toBeTrue();
|
||||
expect($table->persistsSortInSession())->toBeTrue();
|
||||
|
||||
@ -64,7 +64,9 @@
|
||||
->test(ListTenants::class)
|
||||
->assertTableActionDisabled('archive', $tenant)
|
||||
->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant)
|
||||
->callTableAction('archive', $tenant);
|
||||
->callTableAction('archive', $tenant, [
|
||||
'archive_reason' => 'Readonly users should not be able to archive tenants.',
|
||||
]);
|
||||
|
||||
expect($tenant->fresh()->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@ -29,11 +32,17 @@ function tenantWithApp(): Tenant
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'status' => 'ok',
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'consent_granted_at' => now(),
|
||||
'consent_last_checked_at' => now(),
|
||||
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||
'last_health_check_at' => now(),
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
|
||||
@ -3,15 +3,21 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\AuditLog;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
uses(BuildsPortfolioTriageFixtures::class);
|
||||
@ -172,5 +178,52 @@
|
||||
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
||||
->whereNull('resolved_at')
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $actionTenant->workspace_id)
|
||||
->where('tenant_id', (int) $actionTenant->getKey())
|
||||
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
|
||||
->exists())->toBeTrue()
|
||||
->and($component->instance())->toBeInstanceOf(ListTenants::class);
|
||||
});
|
||||
|
||||
it('keeps review-state mutations available on the tenant detail header for the current concern', function (): void {
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Detail Action Tenant');
|
||||
$actionTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Detail Action Backup Tenant');
|
||||
$this->seedPortfolioBackupConcern($actionTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $actionTenant->workspace_id]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $actionTenant->getRouteKey()])
|
||||
->assertActionVisible('markReviewed')
|
||||
->assertActionEnabled('markReviewed')
|
||||
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
||||
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
||||
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
||||
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
||||
->assertActionVisible('markFollowUpNeeded')
|
||||
->assertActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired()
|
||||
&& str_contains((string) $action->getModalDescription(), 'Target state: Follow-up needed')
|
||||
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
||||
->mountAction('markReviewed')
|
||||
->assertActionMounted('markReviewed')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
expect(TenantTriageReview::query()
|
||||
->where('tenant_id', (int) $actionTenant->getKey())
|
||||
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
||||
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
||||
->whereNull('resolved_at')
|
||||
->exists())->toBeTrue()
|
||||
->and(AuditLog::query()
|
||||
->where('workspace_id', (int) $actionTenant->workspace_id)
|
||||
->where('tenant_id', (int) $actionTenant->getKey())
|
||||
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
|
||||
->exists())->toBeTrue()
|
||||
->and($component->instance())->toBeInstanceOf(ViewTenant::class);
|
||||
});
|
||||
|
||||
@ -194,6 +194,9 @@
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->mountAction('archive')
|
||||
->setActionData([
|
||||
'archive_reason' => 'Archiving this tenant from the detail workflow.',
|
||||
])
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
|
||||
@ -65,6 +65,9 @@
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionDisabled('archive')
|
||||
->mountAction('archive')
|
||||
->setActionData([
|
||||
'archive_reason' => 'Readonly users should not be able to archive tenants.',
|
||||
])
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'patched');
|
||||
$service->reopen($finding->refresh(), $tenant, $user);
|
||||
$service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.');
|
||||
$service->close($finding->refresh(), $tenant, $user, 'duplicate');
|
||||
|
||||
expect(AuditLog::query()
|
||||
@ -40,6 +40,11 @@
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate')
|
||||
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
|
||||
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect($reopenedAudit)->not->toBeNull()
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.');
|
||||
});
|
||||
|
||||
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {
|
||||
|
||||
@ -52,7 +52,9 @@
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->filterTable('open', false)
|
||||
->callTableAction('reopen', $finding)
|
||||
->callTableAction('reopen', $finding, [
|
||||
'reopen_reason' => 'The issue recurred in a later scan.',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
->and($resolvedFinding->resolved_reason)->toBe('patched')
|
||||
->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull();
|
||||
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user);
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, 'The issue recurred after remediation.');
|
||||
|
||||
expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopenedFinding->reopened_at)->not->toBeNull()
|
||||
@ -81,6 +81,9 @@
|
||||
expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' '))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
|
||||
|
||||
expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, ' '))
|
||||
->toThrow(\InvalidArgumentException::class, 'reopen_reason is required.');
|
||||
|
||||
expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' '))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
|
||||
});
|
||||
|
||||
@ -35,7 +35,11 @@
|
||||
->assertActionVisible('start_progress');
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $resolvedFinding->getKey()])
|
||||
->assertActionVisible('reopen');
|
||||
->assertActionVisible('reopen')
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
});
|
||||
|
||||
it('executes workflow actions from view header and supports assignment to tenant members only', function (): void {
|
||||
@ -69,7 +73,15 @@
|
||||
->and((int) $finding->owner_user_id)->toBe((int) $user->getKey());
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->callAction('reopen')
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->callAction('reopen', [
|
||||
'reopen_reason' => 'The finding recurred after remediation.',
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->callAction('assign', [
|
||||
'assignee_user_id' => (int) $outsider->getKey(),
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
@ -637,6 +638,92 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->toBe(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'));
|
||||
});
|
||||
|
||||
it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create();
|
||||
[$user, $tenant] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
role: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
ProviderConnection::factory()->platform()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$listComponent = Livewire::actingAs($user)
|
||||
->test(ListTenants::class)
|
||||
->assertTableActionVisible('admin_consent', $tenant)
|
||||
->assertTableActionVisible('open_in_entra', $tenant)
|
||||
->assertTableActionVisible('syncTenant', $tenant)
|
||||
->assertTableActionVisible('verify', $tenant)
|
||||
->assertTableActionVisible('setup_rbac', $tenant)
|
||||
->assertTableActionVisible('archive', $tenant);
|
||||
|
||||
$markReviewedAction = $listComponent->instance()->getTable()->getAction('markReviewed');
|
||||
$markFollowUpNeededAction = $listComponent->instance()->getTable()->getAction('markFollowUpNeeded');
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('admin_consent')
|
||||
->assertActionVisible('open_in_entra')
|
||||
->assertActionVisible('syncTenant')
|
||||
->assertActionVisible('verify')
|
||||
->assertActionVisible('setup_rbac')
|
||||
->assertActionVisible('refresh_rbac')
|
||||
->assertActionVisible('archive');
|
||||
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
$headerGroups = collect($instance->getCachedHeaderActions())
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
||||
->mapWithKeys(static function (ActionGroup $group): array {
|
||||
$actionNames = collect($group->getActions())
|
||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [(string) $group->getLabel() => $actionNames];
|
||||
});
|
||||
|
||||
$visibleHeaderActionNames = $headerGroups
|
||||
->flatMap(static fn (array $actionNames): array => $actionNames)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($markReviewedAction)->not->toBeNull()
|
||||
->and($markReviewedAction?->getName())->toBe('markReviewed')
|
||||
->and($markReviewedAction?->isConfirmationRequired())->toBeTrue()
|
||||
->and($markFollowUpNeededAction)->not->toBeNull()
|
||||
->and($markFollowUpNeededAction?->getName())->toBe('markFollowUpNeeded')
|
||||
->and($markFollowUpNeededAction?->isConfirmationRequired())->toBeTrue()
|
||||
->and(array_keys($headerGroups->all()))->toBe(['External links', 'Setup', 'Triage', 'Lifecycle'])
|
||||
->and($headerGroups->get('External links'))->toEqualCanonicalizing(['admin_consent', 'open_in_entra'])
|
||||
->and($headerGroups->get('Setup'))->toEqualCanonicalizing(['syncTenant', 'verify', 'setup_rbac', 'refresh_rbac'])
|
||||
->and($headerGroups->get('Triage'))->toEqualCanonicalizing(['markReviewed', 'markFollowUpNeeded'])
|
||||
->and($headerGroups->get('Lifecycle'))->toEqualCanonicalizing(['archive'])
|
||||
->and($visibleHeaderActionNames)->not->toContain('edit')
|
||||
->and($visibleHeaderActionNames)->toContain('markReviewed')
|
||||
->and($visibleHeaderActionNames)->toContain('markFollowUpNeeded')
|
||||
->and($visibleHeaderActionNames)->not->toContain('forceDelete')
|
||||
->and(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())->toContain('tenant_edit');
|
||||
});
|
||||
|
||||
it('renders the backup items relation manager on the backup set detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -1939,6 +2026,63 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->and($table->getBulkActions())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('keeps provider connection detail secondary actions aligned under More', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'is_enabled' => true,
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()])
|
||||
->assertActionVisible('grant_admin_consent');
|
||||
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
$headerActions = $instance->getCachedHeaderActions();
|
||||
$primaryHeaderActions = collect($headerActions)
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$moreGroup = collect($headerActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||
$moreActionNames = collect($moreGroup?->getActions())
|
||||
->map(static fn ($action): ?string => $action->getName())
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryHeaderActions)->toEqual(['grant_admin_consent'])
|
||||
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||
->and($moreGroup?->getLabel())->toBe('More')
|
||||
->and($moreActionNames)->toEqualCanonicalizing([
|
||||
'edit',
|
||||
'check_connection',
|
||||
'inventory_sync',
|
||||
'compliance_snapshot',
|
||||
'set_default',
|
||||
'enable_dedicated_override',
|
||||
'rotate_dedicated_credential',
|
||||
'delete_dedicated_credential',
|
||||
'revert_to_platform',
|
||||
'enable_connection',
|
||||
'disable_connection',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
|
||||
it('keeps the spec 194 family inventory, surface bindings, and documented deviations explicit', function (): void {
|
||||
$families = GovernanceActionCatalog::families();
|
||||
$rules = GovernanceActionCatalog::rules();
|
||||
$bindings = GovernanceActionCatalog::surfaceBindings();
|
||||
|
||||
expect(array_keys($families))->toEqualCanonicalizing([
|
||||
'exception_decision',
|
||||
'review_lifecycle',
|
||||
'evidence_lifecycle',
|
||||
'run_triage',
|
||||
'finding_lifecycle',
|
||||
'tenant_lifecycle',
|
||||
])
|
||||
->and(array_keys($rules))->toHaveCount(16)
|
||||
->and($bindings)->not->toBeEmpty();
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
$matchingRule = collect($rules)->first(
|
||||
fn ($rule): bool => $rule->familyKey === $binding['familyKey']
|
||||
&& in_array($binding['surfaceKey'], $rule->surfaceKeys, true),
|
||||
);
|
||||
|
||||
expect($matchingRule)->not->toBeNull();
|
||||
}
|
||||
|
||||
expect(GovernanceActionCatalog::documentedDeviations())->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('keeps evidence and review surface bindings aligned to their canonical action names', function (): void {
|
||||
$bindingsBySurface = collect(GovernanceActionCatalog::surfaceBindings())->groupBy('surfaceKey');
|
||||
|
||||
expect($bindingsBySurface->get('view_evidence_snapshot', collect())->pluck('actionName')->all())
|
||||
->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
|
||||
->and($bindingsBySurface->get('view_tenant_review', collect())->pluck('actionName')->all())
|
||||
->toContain('refresh_review', 'publish_review', 'archive_review');
|
||||
});
|
||||
|
||||
it('keeps triage mutations out of the tenantless run viewer while the system run page owns them', function (): void {
|
||||
$tenantlessViewer = file_get_contents(base_path('app/Filament/Pages/Operations/TenantlessOperationRunViewer.php'));
|
||||
$systemViewRun = file_get_contents(base_path('app/Filament/System/Pages/Ops/ViewRun.php'));
|
||||
|
||||
expect($tenantlessViewer)->toBeString()
|
||||
->and($systemViewRun)->toBeString()
|
||||
->and($tenantlessViewer)->not->toContain("Action::make('retry')")
|
||||
->and($tenantlessViewer)->not->toContain("Action::make('cancel')")
|
||||
->and($tenantlessViewer)->not->toContain("Action::make('mark_investigated')")
|
||||
->and($systemViewRun)->toContain("Action::make('retry')")
|
||||
->and($systemViewRun)->toContain("Action::make('cancel')")
|
||||
->and($systemViewRun)->toContain("Action::make('mark_investigated')");
|
||||
});
|
||||
|
||||
it('keeps the governed surface files inside the catalog binding inventory', function (): void {
|
||||
$boundFiles = collect(GovernanceActionCatalog::surfaceBindings())
|
||||
->pluck('pageClass')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($boundFiles)->toContain(
|
||||
'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
|
||||
'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
|
||||
'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
|
||||
'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
|
||||
'App\\Filament\\System\\Pages\\Ops\\ViewRun',
|
||||
'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
|
||||
'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
|
||||
);
|
||||
});
|
||||
@ -27,7 +27,7 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create();
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user);
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The finding recurred after a later scan.');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->reopened_at)->not->toBeNull()
|
||||
|
||||
@ -74,5 +74,18 @@
|
||||
->assertSee('Related drilldown')
|
||||
->assertDontSee('Quiet monitoring mode')
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
->assertActionVisible('reject_selected_exception');
|
||||
->assertActionVisible('reject_selected_exception')
|
||||
->mountAction('approve_selected_exception')
|
||||
->assertActionMounted('approve_selected_exception')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['approval_reason']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->mountAction('reject_selected_exception')
|
||||
->assertActionMounted('reject_selected_exception')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['rejection_reason']);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
@ -64,6 +65,54 @@
|
||||
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||
});
|
||||
|
||||
it('starts inventory sync from the provider connection detail page', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||
$mock->shouldReceive('listPolicies')->never();
|
||||
$mock->shouldReceive('getPolicy')->never();
|
||||
$mock->shouldReceive('getOrganization')->never();
|
||||
$mock->shouldReceive('applyPolicy')->never();
|
||||
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||
$mock->shouldReceive('request')->never();
|
||||
});
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()])
|
||||
->assertActionVisible('inventory_sync')
|
||||
->callAction('inventory_sync');
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun?->context)->toMatchArray([
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'inventory',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
]);
|
||||
|
||||
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||
});
|
||||
|
||||
it('dedupes compliance snapshot runs and does not call Graph during start', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -40,6 +40,9 @@ function editTenantUiHeaderActions(Testable $component): array
|
||||
&& $action->getTooltip() === 'You do not have permission to archive tenants.';
|
||||
})
|
||||
->mountAction('archive')
|
||||
->setActionData([
|
||||
'archive_reason' => 'Managers should not be able to archive tenants.',
|
||||
])
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
@ -67,6 +70,12 @@ function editTenantUiHeaderActions(Testable $component): array
|
||||
->assertActionEnabled('archive')
|
||||
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
||||
->mountAction('archive')
|
||||
->assertActionMounted('archive')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['archive_reason'])
|
||||
->setActionData([
|
||||
'archive_reason' => 'This tenant is being archived from the edit page.',
|
||||
])
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
|
||||
@ -262,7 +262,7 @@
|
||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||
|
||||
TenantResource::restoreTenant($activeTenant, $auditLogger);
|
||||
TenantResource::archiveTenant($onboardingTenant, $auditLogger);
|
||||
TenantResource::archiveTenant($onboardingTenant, $auditLogger, 'Trying to archive an onboarding tenant should be rejected.');
|
||||
|
||||
$activeTenant->refresh();
|
||||
$onboardingTenant->refresh();
|
||||
|
||||
@ -174,6 +174,8 @@
|
||||
->test(ListTenants::class)
|
||||
->assertTableActionVisible('archive', $tenant)
|
||||
->assertTableActionEnabled('archive', $tenant)
|
||||
->assertTableActionVisible('syncTenant', $tenant)
|
||||
->assertTableActionEnabled('syncTenant', $tenant)
|
||||
->assertTableActionVisible('verify', $tenant)
|
||||
->assertTableActionEnabled('verify', $tenant);
|
||||
|
||||
@ -183,6 +185,8 @@
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive')
|
||||
->assertActionVisible('syncTenant')
|
||||
->assertActionEnabled('syncTenant')
|
||||
->assertActionVisible('verify')
|
||||
->assertActionEnabled('verify');
|
||||
});
|
||||
|
||||
@ -9,10 +9,12 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
@ -33,9 +35,13 @@
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00'));
|
||||
$referenceTime = CarbonImmutable::parse('2026-02-27 10:00:00');
|
||||
|
||||
Carbon::setTestNow($referenceTime);
|
||||
CarbonImmutable::setTestNow($referenceTime);
|
||||
|
||||
$stuckQueued = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(30),
|
||||
@ -43,6 +49,7 @@
|
||||
]);
|
||||
|
||||
$stuckRunning = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(25),
|
||||
@ -50,6 +57,7 @@
|
||||
]);
|
||||
|
||||
$freshQueued = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(5),
|
||||
|
||||
@ -218,9 +218,13 @@
|
||||
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
|
||||
->assertActionVisible('retry')
|
||||
->assertActionExists('retry', fn (Action $action): bool => $action->getLabel() === 'Retry' && $action->isConfirmationRequired())
|
||||
->assertActionHidden('cancel')
|
||||
->assertActionVisible('mark_investigated')
|
||||
->assertActionExists('mark_investigated', fn (Action $action): bool => $action->getLabel() === 'Mark investigated' && $action->isConfirmationRequired())
|
||||
->assertActionHidden('cancel');
|
||||
->mountAction('mark_investigated')
|
||||
->assertActionMounted('mark_investigated')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reason']);
|
||||
|
||||
expect($failedRunView->instance()->getTitle())->toBe('Operation #'.(int) $failedRun->getKey());
|
||||
|
||||
@ -266,8 +270,19 @@
|
||||
->assertActionHidden('retry')
|
||||
->assertActionVisible('cancel')
|
||||
->assertActionExists('cancel', fn (Action $action): bool => $action->getLabel() === 'Cancel' && $action->isConfirmationRequired())
|
||||
->mountAction('cancel')
|
||||
->assertActionMounted('cancel')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reason']);
|
||||
|
||||
Livewire::test(ViewRun::class, [
|
||||
'run' => $runningRun,
|
||||
])
|
||||
->assertActionVisible('cancel')
|
||||
->assertActionVisible('mark_investigated')
|
||||
->callAction('cancel')
|
||||
->callAction('cancel', data: [
|
||||
'reason' => 'Stopping the in-flight run after operator triage.',
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Run cancelled');
|
||||
|
||||
@ -278,7 +293,8 @@
|
||||
$runningRun->refresh();
|
||||
|
||||
expect((string) $runningRun->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||
->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value)
|
||||
->and(data_get($runningRun->context, 'triage.cancel_reason'))->toBe('Stopping the in-flight run after operator triage.');
|
||||
});
|
||||
|
||||
it('keeps detail inspection and navigation available while hiding triage for view-only operators', function () {
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
->assertActionEnabled('restore')
|
||||
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionDoesNotExist('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps archived tenant detail inspectable for readonly members while blocking lifecycle mutation', function (): void {
|
||||
@ -72,7 +72,7 @@
|
||||
->assertActionVisible('restore')
|
||||
->assertActionDisabled('restore')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionDoesNotExist('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps archived tenant routes authoritative when another tenant is currently selected', function (): void {
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
$review = $reviewService->refresh($review, $user, $refreshSnapshot);
|
||||
$review = $reviewService->compose($review->fresh());
|
||||
|
||||
$published = $lifecycle->publish($review, $user);
|
||||
$published = $lifecycle->publish($review, $user, 'Publishing the current review pack.');
|
||||
|
||||
EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -70,7 +70,7 @@
|
||||
operationRunCount: 3,
|
||||
));
|
||||
|
||||
$lifecycle->archive($nextReview, $user);
|
||||
$lifecycle->archive($nextReview, $user, 'Replacing with a newer governance review.');
|
||||
|
||||
expect(AuditLog::query()->where('action', AuditActionId::TenantReviewCreated->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewRefreshed->value)->exists())->toBeTrue()
|
||||
@ -84,7 +84,19 @@
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$publishAudit = AuditLog::query()
|
||||
->where('action', AuditActionId::TenantReviewPublished->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$archiveAudit = AuditLog::query()
|
||||
->where('action', AuditActionId::TenantReviewArchived->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($exportAudit)->not->toBeNull()
|
||||
->and($exportAudit?->resource_type)->toBe('tenant_review')
|
||||
->and(data_get($exportAudit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey());
|
||||
->and(data_get($exportAudit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
|
||||
->and(data_get($publishAudit?->metadata, 'reason'))->toBe('Publishing the current review pack.')
|
||||
->and(data_get($archiveAudit?->metadata, 'reason'))->toBe('Replacing with a newer governance review.');
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
$publishedReview = app(TenantReviewLifecycleService::class)->publish(
|
||||
composeTenantReviewForTest($tenant, $user),
|
||||
$user,
|
||||
'Ready for the next review cycle.',
|
||||
);
|
||||
|
||||
EvidenceSnapshot::query()
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
->and($truth->primaryLabel)->toBe('Publication blocked')
|
||||
->and($truth->nextStepText())->toBe('Resolve the review blockers before publication');
|
||||
|
||||
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user))
|
||||
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user, 'Ready for formal publication.'))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
],
|
||||
);
|
||||
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $user);
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $user, 'Ready for formal publication.');
|
||||
$publishedAt = $published->published_at?->toIso8601String();
|
||||
|
||||
expect($published->status)->toBe(TenantReviewStatus::Published->value)
|
||||
@ -67,7 +67,7 @@
|
||||
|
||||
$publishedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($published);
|
||||
|
||||
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user);
|
||||
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user, 'Superseded by newer review cycle.');
|
||||
$archivedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($archived);
|
||||
|
||||
expect($archived->status)->toBe(TenantReviewStatus::Archived->value)
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -85,6 +86,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
|
||||
$review = composeTenantReviewForTest($tenant, $owner);
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_review');
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
@ -101,20 +103,28 @@ function tenantReviewContractHeaderActions(Testable $component): array
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionExists('refresh_review', fn (Action $action): bool => $action->getLabel() === $refreshRule->canonicalLabel
|
||||
&& $action->isConfirmationRequired()
|
||||
&& $action->getModalHeading() === $refreshRule->modalHeading
|
||||
&& $action->getModalDescription() === $refreshRule->modalDescription)
|
||||
->mountAction('refresh_review')
|
||||
->assertActionMounted('refresh_review');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->mountAction('publish_review')
|
||||
->assertActionMounted('publish_review');
|
||||
->assertActionMounted('publish_review')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['publish_reason']);
|
||||
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $owner);
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $owner, 'Ready for publication.');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $published->getKey()])
|
||||
->mountAction('archive_review')
|
||||
->assertActionMounted('archive_review');
|
||||
->assertActionMounted('archive_review')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['archive_reason']);
|
||||
});
|
||||
|
||||
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {
|
||||
|
||||
@ -120,3 +120,26 @@
|
||||
->assertSuccessful()
|
||||
->assertSee('All tenants');
|
||||
});
|
||||
|
||||
it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->from('/admin/evidence')
|
||||
->post(route('admin.clear-tenant-context'))
|
||||
->assertRedirect(route('admin.evidence.overview'));
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.evidence.overview'))
|
||||
->assertSuccessful()
|
||||
->assertSee('No evidence snapshots in this scope');
|
||||
});
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
'due_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user);
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The issue recurred after verification.');
|
||||
|
||||
expect($reopened->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
|
||||
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
|
||||
it('keeps the spec 194 governance catalog aligned to the approved action matrix', function (
|
||||
string $actionKey,
|
||||
GovernanceFrictionClass $expectedFriction,
|
||||
GovernanceReasonPolicy $expectedReasonPolicy,
|
||||
string $expectedLabel,
|
||||
): void {
|
||||
$rule = GovernanceActionCatalog::rule($actionKey);
|
||||
|
||||
expect($rule->frictionClass)->toBe($expectedFriction)
|
||||
->and($rule->reasonPolicy)->toBe($expectedReasonPolicy)
|
||||
->and($rule->canonicalLabel)->toBe($expectedLabel)
|
||||
->and($rule->requiresConfirmation())->toBeTrue()
|
||||
->and($rule->surfaceKeys)->not->toBeEmpty();
|
||||
})->with([
|
||||
'approve exception' => ['approve_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Approve exception'],
|
||||
'reject exception' => ['reject_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Reject exception'],
|
||||
'renew exception' => ['renew_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Renew exception'],
|
||||
'revoke exception' => ['revoke_exception', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Revoke exception'],
|
||||
'refresh review' => ['refresh_review', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Refresh review'],
|
||||
'publish review' => ['publish_review', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Publish review'],
|
||||
'archive review' => ['archive_review', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Archive review'],
|
||||
'refresh evidence' => ['refresh_evidence', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Refresh evidence'],
|
||||
'expire snapshot' => ['expire_snapshot', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Expire snapshot'],
|
||||
'retry run' => ['retry_run', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Retry'],
|
||||
'mark investigated' => ['mark_investigated', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Mark investigated'],
|
||||
'cancel run' => ['cancel_run', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Cancel'],
|
||||
'close finding' => ['close_finding', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Close'],
|
||||
'reopen finding' => ['reopen_finding', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Reopen'],
|
||||
'archive tenant' => ['archive_tenant', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Archive'],
|
||||
'restore tenant' => ['restore_tenant', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Restore'],
|
||||
]);
|
||||
|
||||
it('keeps the f1 actions on the current-release no-rationale path', function (): void {
|
||||
expect(GovernanceActionCatalog::rule('refresh_review')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
|
||||
->and(GovernanceActionCatalog::rule('refresh_evidence')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
|
||||
->and(GovernanceActionCatalog::rule('retry_run')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
|
||||
->and(GovernanceActionCatalog::rule('restore_tenant')->reasonPolicy)->toBe(GovernanceReasonPolicy::None);
|
||||
});
|
||||
|
||||
it('keeps governed surface bindings on canonical action names', function (): void {
|
||||
$bindingsBySurface = collect(GovernanceActionCatalog::surfaceBindings())->groupBy('surfaceKey');
|
||||
|
||||
expect($bindingsBySurface->get('view_evidence_snapshot', collect())->pluck('actionName')->all())
|
||||
->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
|
||||
->and($bindingsBySurface->get('view_tenant_review', collect())->pluck('actionName')->all())
|
||||
->toContain('refresh_review', 'publish_review', 'archive_review');
|
||||
});
|
||||
|
||||
it('documents the current-release deviations instead of relying on silent local overrides', function (): void {
|
||||
$deviations = collect(GovernanceActionCatalog::documentedDeviations())
|
||||
->map(fn (array $deviation): string => $deviation['actionKey'].'@'.$deviation['surfaceKey'])
|
||||
->all();
|
||||
|
||||
expect($deviations)->toContain(
|
||||
'reject_exception@finding_exceptions_queue',
|
||||
'refresh_evidence@view_evidence_snapshot',
|
||||
'retry_run@system_view_run',
|
||||
'restore_tenant@view_tenant',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps every required-reason binding attached to a concrete ui field', function (): void {
|
||||
$rules = collect(GovernanceActionCatalog::rules());
|
||||
$bindingsByAction = collect(GovernanceActionCatalog::surfaceBindings())
|
||||
->groupBy('actionName');
|
||||
|
||||
foreach ([
|
||||
'approve_selected_exception' => 'approve_exception',
|
||||
'reject_selected_exception' => 'reject_exception',
|
||||
'renew_exception' => 'renew_exception',
|
||||
'revoke_exception' => 'revoke_exception',
|
||||
'publish_review' => 'publish_review',
|
||||
'archive_review' => 'archive_review',
|
||||
'expire_snapshot' => 'expire_snapshot',
|
||||
'expire' => 'expire_snapshot',
|
||||
'mark_investigated' => 'mark_investigated',
|
||||
'cancel' => 'cancel_run',
|
||||
'close' => 'close_finding',
|
||||
'reopen' => 'reopen_finding',
|
||||
'archive' => 'archive_tenant',
|
||||
] as $bindingAction => $ruleKey) {
|
||||
$rule = $rules->get($ruleKey);
|
||||
$binding = $bindingsByAction->get($bindingAction, collect())->first();
|
||||
|
||||
expect($rule?->requiresReason())->toBeTrue()
|
||||
->and($binding)->not->toBeNull()
|
||||
->and($binding['uiFieldKey'] ?? null)->not->toBeNull();
|
||||
}
|
||||
});
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-04-09
|
||||
**Last updated**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
@ -90,10 +90,24 @@ ### Platform Operations Maturity
|
||||
|
||||
## Mid-term (2–3 Quarters)
|
||||
|
||||
### Decision-Based Operating Foundations
|
||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||
**Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand.
|
||||
**Why it matters**: Governance inboxes, actionable alerts, and later autonomous-governance features will fail if they land on top of detail-heavy, entity-first navigation. This is the UX/product prerequisite layer for the later MSP Portfolio OS direction.
|
||||
**Depends on**: Current constitution and action-surface hardening, operator-truth work, existing navigation/context specs.
|
||||
**Scope direction**: First the constitution/rule delta, then a surface / IA classification of current product surfaces, then bounded retrofits that demote detail-first flows behind progressive disclosure instead of creating more top-level pages.
|
||||
|
||||
### MSP Portfolio & Operations (Multi-Tenant)
|
||||
Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center.
|
||||
**Source**: 0800-future-features brainstorming, identified as highest priority pillar.
|
||||
**Prerequisite**: Cross-tenant compare (Spec 043 — draft only).
|
||||
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
|
||||
|
||||
### Human-in-the-Loop Autonomous Governance (Decision-Based Operating)
|
||||
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio.
|
||||
**Goal**: Reduce operator work from searching and correlating to approving, rejecting, deferring, or time-boxing deviations while TenantPilot handles the mechanical follow-through.
|
||||
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable governance, not just a browser for runs, evidence, and tenant state.
|
||||
**Depends on**: Decision-Based Operating Foundations, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth.
|
||||
**Scope direction**: Start with governance inbox + decision packs + actionable alerts. Later add automation policies, guardrails, maintenance windows, dual approval, and before/after evidence automation. Keep human approval and auditability central; avoid blind autopilot remediation.
|
||||
|
||||
### Drift & Change Governance ("Revenue Lever #1")
|
||||
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-04-10 (added Compliance Control Catalog & Interpretation Foundation)
|
||||
**Last reviewed**: 2026-04-12 (added decision-based operating foundations and autonomous governance track)
|
||||
|
||||
---
|
||||
|
||||
@ -708,6 +708,36 @@ ### Recovery Confidence — Automated Restore Testing & Readiness Reporting
|
||||
- **Dependencies**: Restore pipeline stable (Spec 011 and follow-ups), backup infrastructure mature, dry-run/preview infrastructure (restore preview), audit log foundation (Spec 134), RBAC/capability system (066+), evidence/reporting direction for downstream consumption
|
||||
- **Priority**: medium (high strategic value and strong product differentiation potential, but depends on restore pipeline maturity and is realistically sequenced after current hardening work)
|
||||
|
||||
### Decision-First Operating Constitution Hardening
|
||||
- **Type**: foundation
|
||||
- **Source**: product strategy discussion 2026-04-12, constitution delta analysis against the decision-first operating model
|
||||
- **Problem**: TenantPilot already has strong constitution rules around truth, action surfaces, and progressive disclosure, but the product identity and review gates needed for a decision-first governance model are still distributed across separate rules. Upcoming MSP portfolio and governance-inbox work could otherwise reproduce entity-first, detail-heavy surfaces under a new label.
|
||||
- **Why it matters**: This is the rules-before-features step. Without it, later governance automation lands on the wrong UX contract and the product keeps growing by adding more pages instead of reducing operator attention load.
|
||||
- **Proposed direction**:
|
||||
- Make TenantPilot's decision-first governance identity explicit in the constitution
|
||||
- Add workflow-first navigation and "one case, one decision context" as binding principles
|
||||
- Add an automation guardrail: automation must reduce attention load, not create more UI
|
||||
- Extend spec/PR review gates with surface-role classification, human-in-the-loop justification, immediate-vs-on-demand information checks, and explicit search/click-load questions
|
||||
- Treat this as a targeted evolution of the existing constitution, not as a second parallel manifesto
|
||||
- **Explicit non-goals**: Not a visual redesign. Not a rewrite of every current screen. Not a second constitution document.
|
||||
- **Dependencies**: Existing constitution baseline, action surface contract work, operator-truth vocabulary, current navigation/context hardening specs
|
||||
- **Priority**: high
|
||||
|
||||
### Surface Taxonomy & Workflow-First IA Classification
|
||||
- **Type**: foundation
|
||||
- **Source**: product strategy discussion 2026-04-12, decision-first operating follow-up
|
||||
- **Problem**: TenantPilot has grown strong per-domain depth across operations, evidence, baselines, reviews, alerts, and tenant detail surfaces, but there is no canonical classification of which screens are primary decision surfaces, which are secondary context surfaces, and which are tertiary evidence/diagnostic surfaces. Existing navigation and prominence can still mirror entities and implementation structure more than operator workflows.
|
||||
- **Why it matters**: Without a classification-first audit, retrofits will stay local and future governance automation will just add another layer on top of the current detail-page landscape. This candidate produces the target IA truth before new inbox or portfolio-operating surfaces land.
|
||||
- **Proposed direction**:
|
||||
- Catalogue major existing surfaces and classify them as primary decision, secondary context, or tertiary evidence/diagnostic
|
||||
- Evaluate which information must be immediate versus on-demand per surface
|
||||
- Identify where multiple pages should collapse into one decision context, and where detail should move behind tabs, drawers, or expansion
|
||||
- Produce a workflow-first target IA and a bounded retrofit map rather than one umbrella redesign
|
||||
- Reuse existing implemented/draft specs where they already solve local parts of the problem instead of inventing a second parallel IA program
|
||||
- **Explicit non-goals**: Not immediate implementation of all retrofits. Not a mass rewrite of every navigation group in one release. Not a justification for deleting audit or diagnostic depth.
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
||||
- **Priority**: high
|
||||
|
||||
### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
||||
- **Type**: feature
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners
|
||||
@ -723,9 +753,26 @@ ### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
||||
- **Explicit non-goals**: Not a replacement for per-tenant dashboards or detail views (those remain the primary tenant-level surfaces). Not a generic BI/data warehouse initiative or a drag-and-drop report builder. Not a customer-facing analytics suite — this is an operator/MSP-internal tool. Not a cross-tenant compare/diff/promotion surface (that is the Cross-Tenant Compare & Promotion candidate). Not a system-console-level platform triage view (that is the System Console Multi-Workspace Operator UX candidate). Not a replacement for alerting (Specs 099/100 handle event-driven notifications; this is a review/monitoring surface).
|
||||
- **Boundary with Cross-Tenant Compare & Promotion**: Portfolio Dashboard = fleet-level monitoring, health aggregation, SLA reporting, operational overview. Cross-Tenant Compare = policy-level diff, staging-to-production promotion, configuration comparison. They share the multi-tenant dimension but solve fundamentally different problems.
|
||||
- **Boundary with System Console Multi-Workspace Operator UX**: Portfolio Dashboard = workspace-scoped MSP operator view, health/SLA/governance focus. System Console = platform-level triage, cross-workspace operator tooling, infrastructure focus. Different audiences, different panels.
|
||||
- **Dependencies**: Per-tenant operational health signals (backup, sync, drift, findings, provider connection status), workspace model, tenant inventory, alerting foundations (Specs 099/100), RBAC/capability system (066+)
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, Surface Taxonomy & Workflow-First IA Classification, per-tenant operational health signals (backup, sync, drift, findings, provider connection status), workspace model, tenant inventory, alerting foundations (Specs 099/100), RBAC/capability system (066+)
|
||||
- **Priority**: medium (high strategic value, significant data aggregation effort; depends on per-tenant signal maturity)
|
||||
|
||||
### Human-in-the-Loop Autonomous Governance / Governance Inbox
|
||||
- **Type**: feature
|
||||
- **Source**: product strategy discussion 2026-04-12, MSP Portfolio OS direction
|
||||
- **Problem**: Operators still have to pull together drift, evidence, operation runs, policy versions, alerts, and exception history themselves to decide what to do next. Alerting can notify, but the product does not yet provide a workspace-level decision inbox, structured action payloads, recommended next steps, or controlled execution contracts that let the system continue after approval.
|
||||
- **Why it matters**: This is the long-term decision-based operating moat. TenantPilot stops being just a place to inspect tenant state and becomes the system that detects, triages, drafts the decision, collects approval, executes within guardrails, and preserves the full evidence chain. That is much harder for generic AI tooling to replace than simple configuration explainability.
|
||||
- **Proposed direction**:
|
||||
- Governance inbox for pending decisions across tenants and workflows
|
||||
- Actionable alerts/events with structured payloads that open directly into a decision context rather than a generic detail page
|
||||
- Continuous detection and auto-triage against baselines, findings, accepted deviations, run history, and risk signals
|
||||
- Decision packs: what changed, why it matters, recommended action, blast radius, confidence, approvals required, rollback path
|
||||
- Controlled execution after approval: approve, reject, defer, or accept deviation, with automation policies, maintenance windows, dual approval, and scope guardrails where needed
|
||||
- Closed-loop evidence: before snapshot, approval record, execution run, after snapshot, audit trail, review-pack linkage
|
||||
- **Explicit non-goals**: Not blind autopilot remediation. Not a chat-first admin experience. Not a replacement for drift/change governance, findings, or exception workflows; it orchestrates across them.
|
||||
- **Boundary with Drift Change Governance**: Drift Change Governance owns drift-specific approval, freeze-window, and tamper rules. This candidate owns the broader operating model: inbox, decision routing, recommended actions, controlled execution, and evidence closure across drift, evidence, review, and operational workflows.
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, Surface Taxonomy & Workflow-First IA Classification, MSP Multi-Tenant Portfolio Dashboard & SLA Reporting, drift/findings/exception workflow maturity, actionable alert/event payloads, canonical operation/evidence truth
|
||||
- **Priority**: medium (high strategic value, intentionally sequenced after the decision-first foundations)
|
||||
|
||||
### Policy Lifecycle / Ghost Policies (Spec 900 refresh)
|
||||
- **Type**: feature
|
||||
- **Source**: Spec 900 draft (2025-12-22), HANDOVER risk #9
|
||||
|
||||
@ -4,7 +4,7 @@ # Product Standards
|
||||
> Specs reference these standards; they do not redefine them.
|
||||
> Guard tests enforce critical constraints automatically.
|
||||
|
||||
**Last reviewed**: 2026-03-28
|
||||
**Last reviewed**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, UI-CONST-001, UI-SURF-001, UI-HARD-001, UI-EX-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Repository-required surface naming, route references, and Filament action-matrix metadata are intentional product-operational constraints for this codebase, not implementation design instructions.
|
||||
- No clarification markers remain. Spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,318 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Governance Action Semantics Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for Spec 194 governance action friction, reason, and vocabulary alignment
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 194. The affected
|
||||
surfaces continue to render through Filament and Livewire. The schemas
|
||||
below define the bounded semantic contract for governance action families,
|
||||
friction classes, reason policies, danger expectations, approved surface
|
||||
bindings, and documented deviations.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-governance-action-consumers:
|
||||
- family: exception_decision
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||
- apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
|
||||
- apps/platform/app/Services/Findings/FindingExceptionService.php
|
||||
mustRender:
|
||||
- shared_family_binding
|
||||
- required_reason_for_f2_or_f3_actions
|
||||
- canonical_exception_vocabulary
|
||||
mustNotRender:
|
||||
- local_synonym_drift
|
||||
- undocumented_reason_override
|
||||
- family: review_lifecycle
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||
- apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php
|
||||
mustRender:
|
||||
- publish_vs_archive_semantic_separation
|
||||
- export_remains_f0
|
||||
mustNotRender:
|
||||
- export_as_governance_peer
|
||||
- family: evidence_lifecycle
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php
|
||||
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||
- apps/platform/app/Services/Evidence/EvidenceSnapshotService.php
|
||||
mustRender:
|
||||
- refresh_vs_expire_separation
|
||||
- required_reason_for_expire_when_declared
|
||||
mustNotRender:
|
||||
- refresh_and_expire_equivalent_semantics
|
||||
- family: run_triage
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/System/Pages/Ops/ViewRun.php
|
||||
- apps/platform/app/Services/SystemConsole/OperationRunTriageService.php
|
||||
mustRender:
|
||||
- retry_cancel_investigated_severity_split
|
||||
- required_reason_for_high_impact_actions
|
||||
mustNotRender:
|
||||
- cancel_as_lightweight_follow_up
|
||||
- family: lifecycle_support
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Resources/FindingResource.php
|
||||
- apps/platform/app/Filament/Resources/TenantResource.php
|
||||
- apps/platform/app/Services/Findings/FindingWorkflowService.php
|
||||
mustRender:
|
||||
- consistent_close_reopen_family
|
||||
- consistent_archive_restore_family
|
||||
mustNotRender:
|
||||
- undocumented_surface_specific_override
|
||||
- family: regression_guards
|
||||
sourceFiles:
|
||||
- apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php
|
||||
- apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
|
||||
- apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php
|
||||
paths:
|
||||
/internal/governance-actions/families/{family}:
|
||||
get:
|
||||
summary: Return the logical semantics contract for one governance action family
|
||||
operationId: getGovernanceActionFamilyContract
|
||||
parameters:
|
||||
- name: family
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/FamilyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Logical semantics contract for the requested family
|
||||
content:
|
||||
application/vnd.tenantpilot.governance-action-semantics+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceActionFamilyContract'
|
||||
'404':
|
||||
description: Requested family is not declared in the Spec 194 catalog
|
||||
/internal/governance-actions/surfaces/{surface}:
|
||||
get:
|
||||
summary: Return all governance action bindings for one surface
|
||||
operationId: getGovernanceSurfaceBindings
|
||||
parameters:
|
||||
- name: surface
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Declared governance action bindings for the requested surface
|
||||
content:
|
||||
application/vnd.tenantpilot.governance-action-bindings+json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GovernanceActionSurfaceBinding'
|
||||
'404':
|
||||
description: Requested surface has no Spec 194 governance bindings
|
||||
components:
|
||||
schemas:
|
||||
FamilyKey:
|
||||
type: string
|
||||
enum:
|
||||
- exception_decision
|
||||
- review_lifecycle
|
||||
- evidence_lifecycle
|
||||
- run_triage
|
||||
- finding_lifecycle
|
||||
- tenant_lifecycle
|
||||
- non_governance_navigation
|
||||
SurfaceKey:
|
||||
type: string
|
||||
enum:
|
||||
- finding_exceptions_queue
|
||||
- view_finding_exception
|
||||
- list_evidence_snapshots
|
||||
- view_evidence_snapshot
|
||||
- view_tenant_review
|
||||
- view_finding
|
||||
- tenantless_operation_run_viewer
|
||||
- system_view_run
|
||||
- view_tenant
|
||||
- edit_tenant
|
||||
FrictionClass:
|
||||
type: string
|
||||
enum:
|
||||
- F0
|
||||
- F1
|
||||
- F2
|
||||
- F3
|
||||
ReasonPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- optional
|
||||
- required
|
||||
DangerPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- contextual
|
||||
- required
|
||||
AuditChannel:
|
||||
type: string
|
||||
enum:
|
||||
- tenant_audit
|
||||
- workspace_audit
|
||||
- system_audit
|
||||
- operation_context
|
||||
GovernanceActionRule:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- actionKey
|
||||
- canonicalLabel
|
||||
- frictionClass
|
||||
- reasonPolicy
|
||||
- dangerPolicy
|
||||
- auditVerb
|
||||
- serviceOwner
|
||||
properties:
|
||||
actionKey:
|
||||
type: string
|
||||
canonicalLabel:
|
||||
type: string
|
||||
frictionClass:
|
||||
$ref: '#/components/schemas/FrictionClass'
|
||||
reasonPolicy:
|
||||
$ref: '#/components/schemas/ReasonPolicy'
|
||||
dangerPolicy:
|
||||
$ref: '#/components/schemas/DangerPolicy'
|
||||
modalHeadingPattern:
|
||||
type: string
|
||||
successNotificationPattern:
|
||||
type: string
|
||||
auditVerb:
|
||||
type: string
|
||||
serviceOwner:
|
||||
type: string
|
||||
GovernanceActionSurfaceBinding:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surfaceKey
|
||||
- pageClass
|
||||
- actionName
|
||||
- familyKey
|
||||
- statePredicate
|
||||
- auditChannel
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
pageClass:
|
||||
type: string
|
||||
actionName:
|
||||
type: string
|
||||
familyKey:
|
||||
$ref: '#/components/schemas/FamilyKey'
|
||||
statePredicate:
|
||||
type: string
|
||||
primaryOrSecondary:
|
||||
type: string
|
||||
enum:
|
||||
- primary
|
||||
- secondary
|
||||
capabilityKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
uiFieldKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
auditChannel:
|
||||
$ref: '#/components/schemas/AuditChannel'
|
||||
DocumentedDeviation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- actionKey
|
||||
- surfaceKey
|
||||
- deviationType
|
||||
- rationale
|
||||
- reviewGate
|
||||
properties:
|
||||
actionKey:
|
||||
type: string
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
deviationType:
|
||||
type: string
|
||||
enum:
|
||||
- friction_override
|
||||
- reason_override
|
||||
- danger_override
|
||||
- vocabulary_override
|
||||
rationale:
|
||||
type: string
|
||||
reviewGate:
|
||||
type: string
|
||||
allowedUntil:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
GovernanceActionFamilyContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- familyKey
|
||||
- canonicalObject
|
||||
- rules
|
||||
- bindings
|
||||
- regressionRequirements
|
||||
properties:
|
||||
familyKey:
|
||||
$ref: '#/components/schemas/FamilyKey'
|
||||
canonicalObject:
|
||||
type: string
|
||||
panels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- tenant
|
||||
- admin
|
||||
- system
|
||||
defaultActionOrder:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
defaultMutationScopeSource:
|
||||
type: string
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GovernanceActionRule'
|
||||
bindings:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GovernanceActionSurfaceBinding'
|
||||
deviations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DocumentedDeviation'
|
||||
regressionRequirements:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- requiresGuardCoverage
|
||||
- requiresFeatureCoverage
|
||||
- requiresBrowserSmoke
|
||||
- requiresPositiveAuthCase
|
||||
- requiresNegativeAuthCase
|
||||
properties:
|
||||
requiresGuardCoverage:
|
||||
type: boolean
|
||||
requiresFeatureCoverage:
|
||||
type: boolean
|
||||
requiresBrowserSmoke:
|
||||
type: boolean
|
||||
requiresPositiveAuthCase:
|
||||
type: boolean
|
||||
requiresNegativeAuthCase:
|
||||
type: boolean
|
||||
mustVerifyAuditPropagation:
|
||||
type: boolean
|
||||
149
specs/194-governance-friction-hardening/data-model.md
Normal file
149
specs/194-governance-friction-hardening/data-model.md
Normal file
@ -0,0 +1,149 @@
|
||||
# Data Model: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, enum-backed database field, or long-lived artifact. It reuses existing Filament pages, current mutation services, current audit loggers, and existing authorization helpers while adding a derived planning model for governance-action semantics.
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- existing page and resource routes
|
||||
- existing model ownership and scope semantics
|
||||
- existing capability checks and `UiEnforcement` behavior
|
||||
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
|
||||
- existing domain services that own lifecycle mutation
|
||||
- existing tenant, workspace, and system-plane separation
|
||||
|
||||
This feature changes action semantics, reason consistency, vocabulary, and regression protection only.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### GovernanceActionFamilyEntry
|
||||
|
||||
**Type**: derived semantic inventory entry
|
||||
**Source**: explicit Spec 194 family matrix + code-level guard catalog
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `familyKey` | string | Stable identifier such as `exception_decision`, `review_lifecycle`, or `run_triage` |
|
||||
| `canonicalObject` | string | Operator-facing object noun such as `exception`, `review`, `snapshot`, `run`, `finding`, or `tenant` |
|
||||
| `panels` | array<string> | `tenant`, `admin`, `system` |
|
||||
| `surfaceKeys` | array<string> | Concrete surfaces that host this family |
|
||||
| `defaultActionOrder` | array<string> | Canonical order of verbs inside the family |
|
||||
| `supportsDocumentedDeviation` | boolean | Whether family-level deviations are allowed only when explicitly catalogued |
|
||||
| `defaultMutationScopeSource` | string | Where the family gets its mutation scope wording |
|
||||
|
||||
### GovernanceActionRule
|
||||
|
||||
**Type**: derived action-semantics rule
|
||||
**Source**: governance catalog + concrete action bindings
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `actionKey` | string | Stable identifier such as `approve_exception` or `archive_review` |
|
||||
| `familyKey` | string | Links action to its family |
|
||||
| `frictionClass` | string | `F0`, `F1`, `F2`, or `F3` |
|
||||
| `reasonPolicy` | string | `none`, `optional`, or `required` |
|
||||
| `dangerPolicy` | string | `none`, `contextual`, or `required` |
|
||||
| `canonicalLabel` | string | Operator-facing default button label |
|
||||
| `modalHeadingPattern` | string | Canonical confirmation or reason-capture heading |
|
||||
| `successNotificationPattern` | string | Canonical post-success phrase |
|
||||
| `auditVerb` | string | Stable action wording for audit trail alignment |
|
||||
| `serviceOwner` | string | Existing service or class that owns the mutation |
|
||||
|
||||
### GovernanceActionSurfaceBinding
|
||||
|
||||
**Type**: derived surface binding entry
|
||||
**Source**: existing Filament actions on target pages
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable surface identifier |
|
||||
| `pageClass` | string | Concrete Filament page or resource page class |
|
||||
| `actionName` | string | Local Filament action name |
|
||||
| `familyKey` | string | Family that the local action belongs to |
|
||||
| `statePredicate` | string | Human-readable visibility or state rule |
|
||||
| `primaryOrSecondary` | string | Whether the action is the main governance action or a supporting action on that surface |
|
||||
| `capabilityKey` | string or null | Canonical capability registry entry |
|
||||
| `uiFieldKey` | string or null | Input field name used for reason capture when applicable |
|
||||
| `auditChannel` | string | `tenant_audit`, `workspace_audit`, `system_audit`, or `operation_context` |
|
||||
|
||||
### DocumentedGovernanceDeviation
|
||||
|
||||
**Type**: derived exception entry
|
||||
**Source**: spec-approved deviation list
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `actionKey` | string | The action deviating from its family default |
|
||||
| `surfaceKey` | string | Surface where the deviation occurs |
|
||||
| `deviationType` | string | `friction_override`, `reason_override`, `vocabulary_override`, or `danger_override` |
|
||||
| `rationale` | string | Why the default family rule is insufficient |
|
||||
| `reviewGate` | string | What test or review guard must assert the exception |
|
||||
| `allowedUntil` | string or null | Optional bounded-lifetime note for temporary deviations |
|
||||
|
||||
### GovernanceRegressionExpectation
|
||||
|
||||
**Type**: derived regression entry
|
||||
**Source**: spec guard and test strategy
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `familyKey` | string | The family under protection |
|
||||
| `requiresGuardCoverage` | boolean | Catalog and exception guard required |
|
||||
| `requiresFeatureCoverage` | boolean | At least one concrete page or service test required |
|
||||
| `requiresBrowserSmoke` | boolean | Browser smoke required for high-risk families |
|
||||
| `requiresPositiveAuthCase` | boolean | Positive authorization coverage required |
|
||||
| `requiresNegativeAuthCase` | boolean | Negative authorization coverage required |
|
||||
| `mustVerifyAuditPropagation` | boolean | Required when the family uses reason capture or strong lifecycle mutation |
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
### Family-first rules
|
||||
|
||||
1. Every in-scope governance action resolves first to one `GovernanceActionFamilyEntry`, then to one concrete `GovernanceActionRule`.
|
||||
2. Surface location alone cannot redefine friction class.
|
||||
3. A local action name may differ from the canonical rule only if a documented deviation exists.
|
||||
|
||||
### Friction rules
|
||||
|
||||
1. `F0` actions cannot require confirmation, mandatory reason capture, or danger styling.
|
||||
2. `F1` actions require confirmation but default to no mandatory reason.
|
||||
3. `F2` actions require confirmation plus explicit explanation.
|
||||
4. `F3` actions require confirmation, mandatory reason capture, and strong danger separation.
|
||||
|
||||
### Reason rules
|
||||
|
||||
1. Required reason capture must bind to a concrete UI field and a concrete audit or lifecycle propagation path.
|
||||
2. Optional reason capture cannot silently become required on one surface without a documented deviation.
|
||||
3. Existing structured lifecycle metadata may satisfy propagation only if the operator-entered rationale is preserved.
|
||||
4. In the current release, `refresh_evidence`, `retry_run`, and `restore_tenant` resolve to `reasonPolicy=none`; optional F1 rationale capture is not introduced unless a documented deviation is added.
|
||||
|
||||
### Vocabulary rules
|
||||
|
||||
1. Canonical labels, modal headings, notifications, and audit verbs derive from the same family rule.
|
||||
2. Navigation and export actions cannot borrow governance verbs from formal decision families.
|
||||
3. Euphemistic or implementation-first synonyms are rejected unless explicitly documented.
|
||||
4. Until a direct risk-acceptance surface exists, any indirect risk-acceptance wording must resolve through the exception family canon rather than creating a local synonym family.
|
||||
|
||||
### Deviation rules
|
||||
|
||||
1. Deviations are allowed only when the family default is insufficient for a specific surface or workflow state.
|
||||
2. Every deviation must cite a rationale and a regression check.
|
||||
3. No silent deviations are allowed.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `GovernanceActionFamilyEntry` contains one or more `GovernanceActionRule` entries.
|
||||
- One `GovernanceActionRule` may bind to multiple `GovernanceActionSurfaceBinding` entries.
|
||||
- One `GovernanceActionRule` may have zero or more `DocumentedGovernanceDeviation` entries.
|
||||
- Every family must map to at least one `GovernanceRegressionExpectation` entry.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No derived model may widen tenant, workspace, or system access beyond existing route and helper semantics.
|
||||
- No action may lose current authorization, confirmation, audit, or `OperationRun` behavior when aligned to the catalog.
|
||||
- No `F3` action may remain visually or semantically indistinguishable from nearby `F0` or `F1` actions.
|
||||
- No family may introduce a new synonym set without explicit catalog ownership.
|
||||
- No reason requirement may be added in UI without a corresponding propagation path in the owning service or audit log.
|
||||
316
specs/194-governance-friction-hardening/plan.md
Normal file
316
specs/194-governance-friction-hardening/plan.md
Normal file
@ -0,0 +1,316 @@
|
||||
# Implementation Plan: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
**Branch**: `194-governance-friction-hardening` | **Date**: 2026-04-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 surface layer, existing mutation services, existing audit loggers, and the current RBAC helpers. It explicitly avoids introducing a new workflow engine, new persistence, or a broad UI meta-framework.
|
||||
|
||||
## Summary
|
||||
|
||||
Codify one narrow governance-action semantics contract across tenant, workspace, and system surfaces. Introduce a derived governance action catalog that classifies in-scope actions into explicit friction classes, reason rules, danger semantics, and canonical vocabulary; then align the affected Filament pages and existing mutation services so exception, review, evidence, run-triage, finding-lifecycle, and tenant-lifecycle actions behave consistently. Protect the result with a spec-scoped guard, focused feature tests, RBAC regression coverage, and one browser smoke suite.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`)
|
||||
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned
|
||||
**Testing**: Pest unit, feature, and browser tests run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, with tenant routes under `/admin/t/{tenant}/...`, workspace routes under `/admin/...`, and platform routes under `/system/...`
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Preserve current operator interaction speed, keep render-time governance semantics DB-only with no outbound HTTP, avoid adding polling or additional round trips for confirmation flows, and keep any catalog lookups constant-time and local
|
||||
**Constraints**: No new persistence, no new workflow states, no panel/provider changes, no raw capability strings, no cross-plane authorization drift, destructive-like actions keep `->requiresConfirmation()`, and no new generic execution framework
|
||||
**Scale/Scope**: 8 primary operator surfaces across 3 authorization planes, 6 high-priority governance families, 4 medium or low-priority supporting families, focused changes to existing page classes, services, and tests only
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | The feature does not alter inventory or snapshot truth. It governs action semantics only. |
|
||||
| Read/write separation | PASS | PASS | Existing mutations keep confirmation, audit, and tests. No new write domain is introduced. |
|
||||
| Graph contract path | N/A | N/A | No new Microsoft Graph endpoints or contract-registry changes are planned. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing capability registries and `UiEnforcement` remain authoritative. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Existing scope boundaries remain authoritative on `/admin`, `/admin/t/{tenant}`, and `/system`. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Non-member remains `404`, member-without-capability remains `403`, and server-side checks remain unchanged. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` flows keep their current lifecycle and feedback contract; this feature changes only semantics. |
|
||||
| Data minimization | PASS | PASS | No new persistence, caches, or semantic mirrors are introduced. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The plan adds one narrow derived catalog and guard instead of a new framework or persistence layer. |
|
||||
| UI semantics / few layers | PASS | PASS | The feature uses direct action semantics and targeted builders instead of a new presenter stack. |
|
||||
| Filament-native UI | PASS | PASS | Native Filament actions, action groups, and current shared helpers remain the implementation path. |
|
||||
| Surface taxonomy / decision-first roles | PASS | PASS | Surface roles and action semantics remain aligned with Spec 192 and Spec 193 without reclassifying the affected surfaces. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain inside the existing Filament v5 + Livewire v4 stack. |
|
||||
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced and search settings are not altered. |
|
||||
| Destructive action safety | PASS | PASS | Strong actions continue to execute via confirmed Filament actions plus current authorization. |
|
||||
| Asset strategy | PASS | PASS | No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy APIs.
|
||||
- **Provider registration location**: No panel or provider changes are required; registration remains in `bootstrap/providers.php`.
|
||||
- **Global search**: This feature does not add new globally searchable resources and does not change current resource search behavior.
|
||||
- **Destructive actions**: `Revoke exception`, `Archive review`, `Cancel`, and `Archive` remain execution actions with confirmation and server-side authorization.
|
||||
- **Asset strategy**: No new global or lazy-loaded assets are planned. Existing `filament:assets` deployment behavior remains unchanged.
|
||||
- **Testing plan**: Add one spec-scoped guard layer, focused action and authorization tests, and one browser smoke suite across the highest-risk surfaces.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse existing mutation services and audit loggers; do not add a new governance workflow engine.
|
||||
- Introduce one narrow derived governance-action catalog instead of page-local constants or a broad action meta-framework.
|
||||
- Keep action semantics family-first: exception, review, evidence, run-triage, finding-lifecycle, and tenant-lifecycle.
|
||||
- Treat reason capture as a family contract and extend current services or audit metadata only where the spec requires stronger propagation.
|
||||
- Use the existing three testing layers already proven in this repo: spec guard, focused feature/RBAC tests, and one browser smoke suite.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/`:
|
||||
|
||||
- `research.md`: implementation-shape, reason-propagation, and vocabulary decisions
|
||||
- `data-model.md`: derived governance family, rule, binding, deviation, and regression models
|
||||
- `contracts/governance-action-semantics.logical.openapi.yaml`: internal logical contract for governance-action family rules and surface bindings
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design highlights:
|
||||
|
||||
- Keep all new semantics derived, not persisted.
|
||||
- Model governance rules by action family first, then bind them to concrete page actions.
|
||||
- Reuse existing services as owners of state change, audit logging, and operation behavior.
|
||||
- Centralize only the shared semantics that already have multiple real concrete cases.
|
||||
- Keep surface placement aligned with Spec 192 and Spec 193; Spec 194 governs semantic hardness, not where actions live.
|
||||
|
||||
## Phase 1 - Agent Context Update
|
||||
|
||||
Planned command:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
This feature does not introduce a new technology stack, but the required context refresh still runs after the technical context and design artifacts are complete.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/194-governance-friction-hardening/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── governance-action-semantics.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── Monitoring/
|
||||
│ │ │ │ └── FindingExceptionsQueue.php # MODIFY
|
||||
│ │ │ └── Operations/
|
||||
│ │ │ └── TenantlessOperationRunViewer.php # REVIEW / possible minor alignment only
|
||||
│ │ ├── Resources/
|
||||
│ │ │ ├── FindingResource.php # MODIFY
|
||||
│ │ │ ├── FindingResource/
|
||||
│ │ │ │ └── Pages/
|
||||
│ │ │ │ └── ViewFinding.php # MODIFY
|
||||
│ │ │ ├── FindingExceptionResource/
|
||||
│ │ │ │ └── Pages/
|
||||
│ │ │ │ └── ViewFindingException.php # MODIFY
|
||||
│ │ │ ├── EvidenceSnapshotResource.php # MODIFY
|
||||
│ │ │ ├── EvidenceSnapshotResource/
|
||||
│ │ │ │ └── Pages/
|
||||
│ │ │ │ └── ViewEvidenceSnapshot.php # MODIFY
|
||||
│ │ │ ├── TenantReviewResource/
|
||||
│ │ │ │ └── Pages/
|
||||
│ │ │ │ └── ViewTenantReview.php # MODIFY
|
||||
│ │ │ ├── TenantResource.php # MODIFY
|
||||
│ │ │ └── TenantResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ ├── ViewTenant.php # MODIFY
|
||||
│ │ │ └── EditTenant.php # MODIFY
|
||||
│ │ └── System/
|
||||
│ │ └── Pages/
|
||||
│ │ └── Ops/
|
||||
│ │ └── ViewRun.php # MODIFY
|
||||
│ ├── Services/
|
||||
│ │ ├── Findings/
|
||||
│ │ │ ├── FindingExceptionService.php # MODIFY
|
||||
│ │ │ └── FindingWorkflowService.php # MODIFY
|
||||
│ │ ├── Evidence/
|
||||
│ │ │ └── EvidenceSnapshotService.php # MODIFY
|
||||
│ │ ├── TenantReviews/
|
||||
│ │ │ └── TenantReviewLifecycleService.php # MODIFY
|
||||
│ │ └── SystemConsole/
|
||||
│ │ └── OperationRunTriageService.php # MODIFY
|
||||
│ ├── Support/
|
||||
│ │ └── Ui/
|
||||
│ │ └── GovernanceActions/
|
||||
│ │ ├── GovernanceActionCatalog.php # NEW
|
||||
│ │ ├── GovernanceActionRule.php # NEW
|
||||
│ │ └── Enums/
|
||||
│ │ ├── GovernanceFrictionClass.php # NEW
|
||||
│ │ └── GovernanceReasonPolicy.php # NEW
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Guards/
|
||||
│ │ └── Spec194GovernanceActionSemanticsGuardTest.php # NEW
|
||||
│ ├── Monitoring/
|
||||
│ │ ├── FindingExceptionsQueueHierarchyTest.php # MODIFY
|
||||
│ │ └── FindingExceptionsQueueTest.php # MODIFY
|
||||
│ ├── Findings/
|
||||
│ │ ├── FindingExceptionWorkflowTest.php # MODIFY
|
||||
│ │ ├── FindingExceptionRenewalTest.php # MODIFY
|
||||
│ │ ├── FindingExceptionRevocationTest.php # MODIFY
|
||||
│ │ ├── FindingWorkflowViewActionsTest.php # MODIFY
|
||||
│ │ └── FindingAuditLogTest.php # MODIFY
|
||||
│ ├── Evidence/
|
||||
│ │ └── EvidenceSnapshotResourceTest.php # MODIFY
|
||||
│ ├── TenantReview/
|
||||
│ │ ├── TenantReviewUiContractTest.php # MODIFY
|
||||
│ │ └── TenantReviewLifecycleTest.php # MODIFY
|
||||
│ ├── Operations/
|
||||
│ │ ├── TenantlessOperationRunViewerTest.php # REVIEW / possible extend
|
||||
│ │ └── SystemRunBlockedExecutionNotificationTest.php # REVIEW / possible extend
|
||||
│ ├── Rbac/
|
||||
│ │ ├── TenantLifecycleActionVisibilityTest.php # MODIFY
|
||||
│ │ ├── EditTenantArchiveUiEnforcementTest.php # MODIFY
|
||||
│ │ └── TenantResourceAuthorizationTest.php # MODIFY
|
||||
│ └── Audit/
|
||||
│ └── TenantLifecycleAuditLogTest.php # MODIFY
|
||||
├── Unit/
|
||||
│ └── Ui/
|
||||
│ └── GovernanceActions/
|
||||
│ └── GovernanceActionCatalogTest.php # NEW
|
||||
└── Browser/
|
||||
│ └── Spec194GovernanceFrictionSmokeTest.php # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the work entirely inside the existing Laravel or Filament monolith under `apps/platform`. Add one narrow support namespace for shared governance semantics, then modify the affected page classes, mutation services, and focused tests. Do not introduce new persistence or a second runtime orchestration layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| New derived friction and reason taxonomy | The feature needs one shared project-wide rule for actions that already exist across multiple surfaces and panels. | Local constants and per-page copy edits would not prevent drift or make regression guardable. |
|
||||
| New shared governance-action catalog | Multiple concrete families already exist and need one canonical source for friction, reason, vocabulary, and approved deviations. | Keeping all semantics inside individual page classes would duplicate logic, produce inconsistent naming, and make CI enforcement weak. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Similar governance actions currently carry different semantic weight, reason burden, and vocabulary depending on the surface.
|
||||
- **Existing structure is insufficient because**: Current page-local action definitions and service calls do not provide one guardable source for friction class, reason requirement, or canonical wording across families.
|
||||
- **Narrowest correct implementation**: Add a small derived catalog and enums for friction and reason policy, bind existing actions to those rules, and keep all state change in the current services.
|
||||
- **Ownership cost created**: One small shared support namespace, one spec-scoped guard test, targeted page and service test updates, and one browser smoke suite.
|
||||
- **Alternative intentionally rejected**: A generic governance workflow framework or persisted action matrix was rejected because the repo only needs explicit cross-surface semantics, not a new runtime engine.
|
||||
- **Release truth**: current-release operator safety, auditability, and semantic consistency
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Codify the shared governance semantics contract
|
||||
|
||||
Goal: create one derived, testable source for action families without introducing a new workflow engine.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `GovernanceFrictionClass` and `GovernanceReasonPolicy` enums.
|
||||
- Add `GovernanceActionRule` plus `GovernanceActionCatalog` as the canonical mapping of action family, friction, reason policy, danger expectation, and canonical copy.
|
||||
- Declare the current-release indirect risk-acceptance continuity rule so finding exception semantics remain the canonical carrier until a direct risk-acceptance surface exists.
|
||||
- Add `Spec194GovernanceActionSemanticsGuardTest.php` to ensure every in-scope action family and documented deviation is declared.
|
||||
- Keep the catalog derived only. Do not create DB tables or stored mirrors.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add `GovernanceActionCatalogTest.php` for catalog completeness and invariants.
|
||||
- Add `Spec194GovernanceActionSemanticsGuardTest.php` for project-level inventory and exception coverage.
|
||||
|
||||
### Phase B - Align the highest-risk governance families
|
||||
|
||||
Goal: normalize the surfaces where semantic inconsistency carries the highest operator risk.
|
||||
|
||||
Changes:
|
||||
|
||||
- Align exception decision and lifecycle actions on `FindingExceptionsQueue` and `ViewFindingException`.
|
||||
- Align review publication and archival semantics on `ViewTenantReview`.
|
||||
- Align evidence refresh versus expiry on `EvidenceSnapshotResource` and `ViewEvidenceSnapshot`, keeping `Refresh evidence` as confirmed F1 with no operator-entered reason.
|
||||
- Align run triage semantics on `System ViewRun`, keeping `Retry` as confirmed F1 with no operator-entered reason while `Mark investigated` and `Cancel` keep stronger rationale rules.
|
||||
- Extend or standardize reason propagation in the owning services and audit loggers where F2 or F3 requires it.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend exception workflow and queue tests.
|
||||
- Extend tenant review lifecycle and UI-contract tests.
|
||||
- Extend evidence snapshot resource tests.
|
||||
- Add or extend run-triage tests around `ViewRun`-owned actions and audit behavior.
|
||||
|
||||
### Phase C - Align supporting lifecycle families and preserve calm surfaces
|
||||
|
||||
Goal: finish cross-surface consistency without overcorrecting lower-risk actions.
|
||||
|
||||
Changes:
|
||||
|
||||
- Align finding close and reopen semantics across header, row, and bulk actions in `FindingResource`, `ViewFinding`, and `FindingWorkflowService`.
|
||||
- Align tenant archive and restore semantics across `ViewTenant`, `EditTenant`, `TenantResource`, and current audit logging, keeping `Restore` as confirmed F1 with no operator-entered reason.
|
||||
- Keep indirect risk-acceptance wording aligned with the exception family and document any allowed alias only in the shared catalog.
|
||||
- Review `TenantlessOperationRunViewer` to ensure it stays context-first and does not drift into a triage surface unless justified.
|
||||
- Keep navigation, export, and related-context actions explicitly outside governance friction.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend finding workflow header-, row-, and bulk-action tests, finding audit tests, and finding view-action tests.
|
||||
- Extend tenant lifecycle RBAC, naming, and audit tests.
|
||||
- Extend any affected operation viewer tests only if the viewer surface changes semantics.
|
||||
|
||||
### Phase D - Browser verification and final regression protection
|
||||
|
||||
Goal: prove the new semantics in a real browser and prevent new local exceptions from returning.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `Spec194GovernanceFrictionSmokeTest.php` covering exception queue/detail, review detail, evidence detail, system run detail, and tenant lifecycle surfaces.
|
||||
- Ensure the guard layer fails when a new governance action lacks a declared family, friction class, reason rule, or documented deviation.
|
||||
- Re-run formatting and the focused Sail verification pack.
|
||||
|
||||
Tests:
|
||||
|
||||
- Browser smoke coverage for visible friction, copy, and danger separation.
|
||||
- Focused guard, feature, and authorization tests for all changed families.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| The catalog grows into a general workflow framework | Medium | Low | Keep only friction, reason, vocabulary, and deviation metadata; leave execution in existing services. |
|
||||
| Reason capture is added inconsistently across services | High | Medium | Make reason policy family-owned and test propagation at service and audit levels. |
|
||||
| Low-risk actions accidentally inherit F3 semantics | Medium | Medium | Keep explicit F0 and F1 boundaries in the catalog and browser smoke coverage. |
|
||||
| Surface-specific copy diverges from the family canon | Medium | Medium | Use one catalog source for labels and heading copy where practical and guard with tests. |
|
||||
| Authorization semantics drift while actions are reworked | High | Low | Reuse existing Policies and `UiEnforcement`, and extend positive plus negative RBAC tests. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Add `GovernanceActionCatalogTest.php` so friction, reason, and deviation rules remain internally consistent.
|
||||
- Add `Spec194GovernanceActionSemanticsGuardTest.php` to validate that every in-scope family, indirect risk-acceptance alias, and documented exception is declared.
|
||||
- Extend exception queue/detail tests to assert family-consistent reason prompts and semantic separation.
|
||||
- Extend review, evidence, finding, run, tenant lifecycle, and audit tests where the plan changes semantics or reason propagation, including explicit header-, row-, and bulk-finding lifecycle coverage.
|
||||
- Reuse existing RBAC feature tests to prove non-member `404`, member-without-capability `403`, and correct disabled-state behavior where UI enforcement remains visible.
|
||||
- Add `Spec194GovernanceFrictionSmokeTest.php` using the existing spec-based browser smoke pattern already present in the repo.
|
||||
- Run the focused Sail verification commands from `quickstart.md`, then run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
|
||||
- Provider registration remains unchanged in `bootstrap/providers.php`.
|
||||
- The plan changes no global-search semantics; affected surfaces are existing resource pages or standalone pages whose search behavior is unchanged.
|
||||
- Destructive and governance-changing actions keep `->requiresConfirmation()` plus existing authorization.
|
||||
- No new assets are introduced; existing `filament:assets` deployment behavior remains sufficient.
|
||||
110
specs/194-governance-friction-hardening/quickstart.md
Normal file
110
specs/194-governance-friction-hardening/quickstart.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Quickstart: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
## Goal
|
||||
|
||||
Bring in-scope governance actions under one bounded semantics contract so that similar operator decisions use the same friction class, reason burden, danger semantics, and vocabulary across tenant, workspace, and system surfaces.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Introduce the shared semantics catalog.
|
||||
- Add the Spec 194 governance catalog and its enums.
|
||||
- Define the canonical families, friction classes, reason rules, and approved deviations.
|
||||
- Add the spec guard so new governance actions cannot appear without a declared family rule.
|
||||
|
||||
2. Align the highest-risk families first.
|
||||
- Refactor `FindingExceptionsQueue` and `ViewFindingException` around one exception-decision family.
|
||||
- Refactor `ViewTenantReview` so publish and archive semantics are clearly distinct from export.
|
||||
- Refactor evidence snapshot actions so refresh and expiry no longer behave like equivalent mutations.
|
||||
- Refactor `System ViewRun` so retry, cancel, and mark investigated clearly communicate different severity.
|
||||
|
||||
3. Extend service-level reason and audit propagation where the new family rules require it.
|
||||
- Keep existing services as mutation owners.
|
||||
- Add or standardize reason inputs and audit metadata only where F2 or F3 requires it.
|
||||
- Preserve existing `OperationRun` and notification behavior.
|
||||
|
||||
4. Align supporting lifecycle families.
|
||||
- Harmonize finding close and reopen semantics.
|
||||
- Harmonize tenant archive and restore semantics across view and edit pages.
|
||||
- Verify `TenantlessOperationRunViewer` remains context-first and does not invent local triage semantics.
|
||||
|
||||
5. Add regression protection and browser verification.
|
||||
- Add the spec guard and unit coverage for the catalog.
|
||||
- Extend focused feature and RBAC tests on the affected surfaces.
|
||||
- Add one browser smoke suite that exercises the highest-risk family flows.
|
||||
|
||||
## Suggested Source Files
|
||||
|
||||
- `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php`
|
||||
- `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
|
||||
- `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`
|
||||
- `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
|
||||
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
||||
- `apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php`
|
||||
- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
|
||||
## Suggested Test Files
|
||||
|
||||
- `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
|
||||
- `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/FindingExceptionWorkflowTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/FindingExceptionRenewalTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/FindingExceptionRevocationTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/FindingAuditLogTest.php`
|
||||
- `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
|
||||
- `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`
|
||||
- `apps/platform/tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
|
||||
- `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- `apps/platform/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
|
||||
- `apps/platform/tests/Feature/Audit/TenantLifecycleAuditLogTest.php`
|
||||
- `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
|
||||
|
||||
## Minimum Verification Commands
|
||||
|
||||
Run all commands through Sail from `apps/platform`.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingExceptionWorkflowTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewLifecycleTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec194GovernanceFrictionSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Acceptance Checklist
|
||||
|
||||
1. Open `FindingExceptionsQueue` and verify that approve and reject use the expected friction and reason semantics.
|
||||
2. Open `ViewFindingException` and verify that renew and revoke are clearly differentiated in severity and rationale burden.
|
||||
3. Open `ViewTenantReview` and verify that publish, export, and archive no longer read like equivalent lifecycle peers.
|
||||
4. Open an evidence snapshot detail page and verify that refresh remains lighter than expire.
|
||||
5. Open `System ViewRun` and verify that retry, cancel, and mark investigated communicate different seriousness.
|
||||
6. Open `ViewTenant` and `EditTenant` and verify that archive and restore remain semantically aligned across both surfaces.
|
||||
7. Confirm browser smoke checks show no JavaScript errors on the remediated governance surfaces.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No provider registration change is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
- No new asset registration is expected. Existing deploy handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
139
specs/194-governance-friction-hardening/research.md
Normal file
139
specs/194-governance-friction-hardening/research.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Research: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
## Decision: Introduce one narrow governance-action catalog instead of a new governance workflow framework
|
||||
|
||||
### Rationale
|
||||
|
||||
Spec 194 needs one project-wide, testable source for friction class, reason policy, danger expectation, and canonical vocabulary across actions that already exist on multiple surfaces. The repo already has several concrete governance families: exception decisions, review lifecycle, evidence lifecycle, run triage, finding lifecycle, and tenant lifecycle. That is enough real variance to justify one small derived catalog, but not a new runtime workflow engine.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep all semantics page-local and document them only in the spec: rejected because local copy and modal logic would drift again and CI could not enforce the rules.
|
||||
- Build a full governance action framework with custom builders, registries, and resolvers: rejected because the repo only needs shared semantics, not a second execution engine.
|
||||
|
||||
## Decision: Keep existing mutation services and audit loggers as owners of state change
|
||||
|
||||
### Rationale
|
||||
|
||||
The current services already own the actual lifecycle mutation and most audit logging:
|
||||
|
||||
- `FindingExceptionService` for approve, reject, renew, revoke
|
||||
- `TenantReviewLifecycleService` for publish and archive
|
||||
- `EvidenceSnapshotService` for refresh and expire
|
||||
- `OperationRunTriageService` for retry, cancel, and mark investigated
|
||||
- `FindingWorkflowService` for close and reopen
|
||||
- `TenantResource` lifecycle helpers plus `WorkspaceAuditLogger` for archive and restore
|
||||
|
||||
The narrowest correct implementation is to align UI semantics and extend service inputs or audit metadata only where Spec 194 requires stronger reason propagation.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Move lifecycle mutations into a new shared governance service layer: rejected because it would duplicate working domain services and add coordination overhead without solving a new business problem.
|
||||
- Keep reason capture only in UI and not in service-level inputs: rejected because Spec 194 requires reasons to remain audit-visible and not be purely presentational.
|
||||
|
||||
## Decision: Treat reason capture as a family contract, not a local modal choice
|
||||
|
||||
### Rationale
|
||||
|
||||
Current repo behavior is inconsistent:
|
||||
|
||||
- Exception family already captures reasons across all four major actions.
|
||||
- Review publish or archive capture no reason.
|
||||
- Evidence refresh or expire capture no reason.
|
||||
- System run triage captures reason only for `Mark investigated`, not for `Cancel`.
|
||||
- Finding `Close` captures reason, but `Reopen` does not.
|
||||
- Tenant archive or restore capture no reason.
|
||||
|
||||
Spec 194 therefore must define reason policy by family and then drive the UI forms and service inputs from that rule.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Leave reason capture to each page owner: rejected because it produced the current inconsistency.
|
||||
- Force a reason on every action: rejected because it would over-harden F0 and F1 actions and reduce operator velocity without safety benefit.
|
||||
|
||||
## Decision: Distinguish technical refresh from formal governance lifecycle
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already shows that similarly placed actions do not have equivalent business meaning:
|
||||
|
||||
- `Refresh evidence` is operational regeneration of data.
|
||||
- `Expire snapshot` formally invalidates a governance artifact.
|
||||
- `Refresh review` is operational recomputation.
|
||||
- `Publish review` is a formal release step.
|
||||
- `Retry` is follow-up work.
|
||||
- `Cancel` is a stronger intervention.
|
||||
|
||||
Spec 194 should therefore classify by business impact, not by whether the action appears in a header or uses the same Filament primitive.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Classify by surface location: rejected because the same family appears on queue, detail, workspace, and system pages.
|
||||
- Classify by current button color: rejected because current color usage is part of the inconsistency.
|
||||
|
||||
## Decision: Use canonical operator vocabulary per family and prohibit casual synonyms
|
||||
|
||||
### Rationale
|
||||
|
||||
The same domain effect should not oscillate between verbs. The current repo already has stable families that can be hardened:
|
||||
|
||||
- `Approve / Reject`
|
||||
- `Renew exception / Revoke exception`
|
||||
- `Publish review / Archive review / Create next review`
|
||||
- `Refresh evidence / Expire snapshot`
|
||||
- `Close / Reopen`
|
||||
- `Retry / Cancel / Mark investigated`
|
||||
- `Archive / Restore`
|
||||
|
||||
Spec 194 should preserve those families and use them consistently in action labels, modal headings, notifications, and audit wording.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Allow page-specific synonyms where copy “reads better”: rejected because operator ambiguity is precisely the problem this spec is solving.
|
||||
- Rename everything to one generic lifecycle lexicon: rejected because different domains still need domain-specific objects and verbs.
|
||||
|
||||
## Decision: Keep the new semantics derived and guardable, not persisted
|
||||
|
||||
### Rationale
|
||||
|
||||
The new friction classes and reason policies are product rules, not new domain records. They do not need their own table or long-lived artifact. A derived catalog plus tests is enough to make the rules explicit, reviewable, and regression-safe.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Persist the matrix in the database or a user-editable admin screen: rejected because the semantics are part of product behavior, not tenant-owned configuration.
|
||||
- Leave the matrix only in documentation: rejected because the repo needs an enforceable regression gate.
|
||||
|
||||
## Decision: Reuse the existing test layering already proven in this repo
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already has the right three layers for Spec 194:
|
||||
|
||||
- Guard tests for contract-level invariants
|
||||
- Focused feature or RBAC tests around concrete surfaces and services
|
||||
- Browser smoke tests for cross-surface operator flows
|
||||
|
||||
This gives durable coverage without overbuilding.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Browser-test every friction permutation: rejected because service and page tests already cover most of the logic more cheaply.
|
||||
- Add only a unit test for the catalog: rejected because surface wiring and authorization semantics would remain unverified.
|
||||
|
||||
## Decision: Align the highest-risk families first
|
||||
|
||||
### Rationale
|
||||
|
||||
The strongest current inconsistencies and operator risks are concentrated in:
|
||||
|
||||
- Exception decision and lifecycle actions
|
||||
- Review publication and archival
|
||||
- Evidence expiry semantics
|
||||
- System run triage
|
||||
|
||||
These should be aligned before lower-risk supporting families such as tenant restore or navigation-adjacent actions.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Start with the broadest surface rollout: rejected because it would spread effort without first hardening the most consequential actions.
|
||||
- Start with tenant lifecycle only: rejected because exception, review, evidence, and run triage already carry higher governance importance.
|
||||
365
specs/194-governance-friction-hardening/spec.md
Normal file
365
specs/194-governance-friction-hardening/spec.md
Normal file
@ -0,0 +1,365 @@
|
||||
# Feature Specification: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
**Feature Branch**: `194-governance-friction-hardening`
|
||||
**Created**: 2026-04-12
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 194 - Governance Friction Hardening & Operator Vocabulary"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Governance-relevant actions across the admin panel still use inconsistent confirm depth, reason capture, danger styling, and operator wording for equivalent decisions.
|
||||
- **Today's failure**: Operators can perform similar exception, review, evidence, run-triage, and tenant lifecycle actions with different semantic weight depending on the surface, which weakens audit clarity and increases misjudgment risk.
|
||||
- **User-visible improvement**: Similar governance decisions will feel the same across queue, detail, workspace, and system surfaces, while harmless navigation and export actions remain visibly lighter.
|
||||
- **Smallest enterprise-capable version**: Inventory all in-scope governance actions, group them into action families, assign one friction class per action, align the highest-risk families first, document exceptions, and add a lightweight regression gate for future actions.
|
||||
- **Explicit non-goals**: No new governance states, no new workflow engine, no header-layout rewrite, no dispatch/preflight refactor, no new audit domain, and no generic action DSL.
|
||||
- **Permanent complexity imported**: A small semantic layer with friction classes F0-F3, reason rules, a vocabulary canon, and a documented exception path.
|
||||
- **Why now**: Spec 192 and Spec 193 solve surface placement and hierarchy, but not the semantics of the governance actions themselves.
|
||||
- **Why not local**: The same action families already appear across queue, detail, workspace, and system surfaces, so page-by-page fixes would not stop drift.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Cross-surface taxonomy risk and multi-surface remediation breadth risk. Defense: no new persistence, no new domain workflow, and no generic framework beyond the smallest shared rule model.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Existing tenant admin detail pages under `/admin/t/{tenant}/...` for findings, finding exceptions, evidence snapshots, and tenant reviews
|
||||
- Existing workspace admin surfaces under `/admin/...` for finding exception queueing, operations, audit, and workspace tenant lifecycle
|
||||
- Existing system operation run detail pages under `/system/ops/...`
|
||||
- **Data Ownership**:
|
||||
- No new tables, persisted entities, or ownership boundaries are introduced.
|
||||
- Tenant-owned records remain tenant-owned: findings, finding exceptions, evidence snapshots, and tenant reviews.
|
||||
- Workspace queue and monitoring pages continue to present existing workspace-visible or entitled cross-tenant views only.
|
||||
- System run pages continue to expose existing platform-visible run records only.
|
||||
- **RBAC**:
|
||||
- Tenant admin plane `/admin/t/{tenant}` keeps tenant membership plus capability checks for finding, exception, evidence, and review actions.
|
||||
- Workspace admin plane `/admin` keeps workspace membership and capability checks for queue review and tenant lifecycle actions.
|
||||
- System plane `/system` keeps platform capabilities for operational triage.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Workspace monitoring pages may keep an entitled tenant prefilter or remembered context, but the same action family must retain the same friction class whether the operator arrived through workspace scope or a narrower tenant-prefiltered route.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Re-grouping, relabeling, or friction alignment must not bypass existing Gates, Policies, UiEnforcement helpers, or capability registries. Non-members remain deny-as-not-found, scoped records remain tenant-safe, and workspace/system pages must not hint at inaccessible tenant detail.
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | Primary Decision Surface | Approve or reject a pending exception request | Tenant, finding summary, validity, request age, and whether a decision is pending now | Tenant detail, finding detail, request history, evidence references | Primary because this is the workspace approval inbox | Follows pending-governance review, not storage objects | Removes switching between queue, finding detail, and tenant detail before deciding |
|
||||
| ViewFindingException | Primary Decision Surface | Renew or revoke an existing exception | Current validity, expiry, owner, status, and whether renewal or revocation is allowed | Related finding, evidence references, prior decisions | Primary because active exception lifecycle should remain decidable on one page | Follows exception maintenance workflow | Avoids reconstructing state from audit or related records |
|
||||
| ViewEvidenceSnapshot | Secondary Context Surface | Decide whether evidence remains valid and inspect its current truth | Artifact truth, completeness, status, expiry, and whether expiration is available | Operation run, review pack, raw evidence dimensions | Not primary because visits are usually inspection-first, but lifecycle mutation still needs governed semantics | Supports evidence inspection with occasional lifecycle intervention | Prevents refresh and expiry from reading as equivalent generic mutations |
|
||||
| ViewTenantReview | Primary Decision Surface | Publish, archive, or continue a review lifecycle | Review status, readiness, publication state, and next lifecycle step | Pack export and deeper evidence context | Primary because review publication is a formal governance moment | Follows review release workflow | Prevents publish, export, and archive from blurring together |
|
||||
| ViewFinding | Secondary Context Surface | Close, reopen, or route a finding into exception governance | Current status, severity, governance posture, and queue availability | Related records and deeper operational evidence | Secondary because it often feeds a later governance step | Aligns with finding triage and escalation | Keeps queue/navigation actions distinct from lifecycle mutation |
|
||||
| TenantlessOperationRunViewer | Secondary Context Surface | Inspect one run and understand whether any follow-up exists | Run identity, scope, outcome, freshness, and follow-up availability | Related links, restore continuation, detailed diagnostics | Secondary because the surface is context-first unless it genuinely owns intervention | Supports monitoring review before intervention | Prevents context and navigation from feeling like governance mutation |
|
||||
| System ViewRun | Primary Decision Surface | Retry, cancel, or mark a run investigated | Run identity, current outcome, retryability, cancellability, and investigation need | Related runbooks and downstream operational context | Primary because it is the platform triage point for run intervention | Follows operations workflow | Makes the difference between retry, cancel, and investigated immediately legible |
|
||||
| ViewTenant / EditTenant | Primary Decision Surface | Archive or restore a tenant lifecycle state | Current lifecycle state and currently allowed lifecycle action | Supporting setup and external context | Primary because tenant lifecycle is a formal operating-state decision | Follows tenant lifecycle governance | Prevents archive/restore from behaving like routine utilities |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | Queue / Workbench | Governance decision workbench | Approve or reject the selected request | Explicit inspect action selects the request and opens in-page detail | forbidden | Scope, filters, and related navigation stay outside the decision lane | Review-selected decision group only | Existing workspace exception queue route | Same page with selected exception context plus tenant detail drilldown | Workspace scope and optional tenant prefilter | Finding exceptions / exception request | Whether a decision-ready request is selected now | none |
|
||||
| ViewFindingException | Detail / Decision | Governance lifecycle detail | Renew or revoke the exception | Canonical tenant detail page | not applicable | Safe related navigation stays secondary | Revocation remains separated from renewal | Existing tenant exception list route | Existing tenant exception detail route | Active tenant context only | Finding exception / exception | Whether the exception is active, expiring, or revocable | none |
|
||||
| ListEvidenceSnapshots | List / Table | Read-only registry report with lifecycle row action | Inspect a snapshot or expire an obsolete one | Clickable row opens snapshot detail | required | Row-level More menu only for non-primary mutation | `Expire snapshot` stays grouped under More | Existing tenant evidence index route | Existing tenant evidence detail route | Active tenant context only | Evidence snapshots / snapshot | Current evidence truth and next step | none |
|
||||
| ViewEvidenceSnapshot | Detail / Context | Evidence lifecycle detail | Refresh evidence or expire snapshot | Canonical tenant snapshot detail page | not applicable | Related operation/review navigation stays contextual | `Expire snapshot` remains danger-separated from `Refresh evidence` | Existing tenant evidence index route | Existing tenant evidence detail route | Active tenant context only | Evidence snapshot / snapshot | Whether the snapshot is current, complete, and still valid | none |
|
||||
| ViewTenantReview | Detail / Decision | Governance release detail | Publish review, export pack, or archive review | Canonical tenant review detail page | not applicable | Export and next-review actions stay secondary | Archive stays in a separate danger group | Existing tenant review register route | Existing tenant review detail route | Active tenant context only | Tenant reviews / review | Publication readiness and current lifecycle state | none |
|
||||
| ViewFinding | Detail / Context | Finding lifecycle and governance context detail | Close, reopen, or route into exception governance | Canonical tenant finding detail page | not applicable | Related record and queue navigation stay separate from workflow mutation | Any destructive-like lifecycle action stays in the workflow family, not as navigation | Existing tenant findings route | Existing tenant finding detail route | Active tenant context only | Findings / finding | Governance posture and next action | none |
|
||||
| TenantlessOperationRunViewer | Detail / Monitoring | Workspace run context viewer | Refresh or follow related run context | Canonical workspace operation detail page | forbidden | Scope, return, refresh, and related links remain structured and context-first | No destructive triage action is promoted unless the surface genuinely owns it | Existing workspace operations route | Existing tenantless run detail route | Workspace scope and optional tenant-origin context | Operations / operation run | Whether the run needs attention and what follow-up exists | context-first viewer |
|
||||
| System ViewRun | Detail / Decision | Platform run triage detail | Retry, cancel, or mark the run investigated | Canonical system run detail page | forbidden | Navigation back to runs and runbooks stays secondary | Cancel is clearly separated as the strongest action | Existing system runs route | Existing system run detail route | Platform/system scope only | Operations / operation run | Whether intervention is possible and how severe it is | none |
|
||||
| ViewTenant / EditTenant | Detail / Lifecycle | Workspace tenant lifecycle surface | Archive or restore the tenant | Canonical workspace tenant view/edit pages | not applicable | Setup and external links remain outside lifecycle semantics | Archive and restore stay inside one lifecycle family with explicit severity | Existing workspace tenant register route | Existing workspace tenant view/edit routes | Workspace scope only | Tenants / tenant | Whether the tenant is active or archived and which lifecycle action is allowed | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | Workspace approver | Approve or reject pending exception requests | Queue workbench | What request needs a formal decision right now? | Request state, tenant, finding summary, validity, review urgency, selection state | Related finding detail and full tenant exception detail | governance validity, request status, urgency | TenantPilot only | Approve exception, Reject exception | Reject exception may be negative governance, but does not automatically become F3 |
|
||||
| ViewFindingException | Tenant manager | Renew or revoke an existing exception | Detail decision surface | Is this exception still justified, and what lifecycle step is warranted now? | Current validity, status, owner, expiry, renewal eligibility, revocation eligibility | Evidence references and decision history | lifecycle, governance validity, expiry | TenantPilot only | Renew exception | Revoke exception |
|
||||
| ViewEvidenceSnapshot | Tenant operator | Refresh or formally expire the active evidence basis | Detail context surface | Is this evidence still valid to govern from? | Artifact truth, completeness, freshness, expiry, latest related operation | Review-pack and operation drilldowns, raw evidence dimensions | evidence validity, completeness, freshness | TenantPilot only | Refresh evidence | Expire snapshot |
|
||||
| ViewTenantReview | Tenant reviewer | Publish or archive review lifecycle state | Detail decision surface | Is this review ready for formal release, continuation, or closure? | Review status, readiness, summary truth, next lifecycle step | Exported pack and supporting evidence detail | lifecycle, completeness, readiness | TenantPilot only | Publish review, Create next review | Archive review |
|
||||
| ViewFinding | Tenant operator | Close, reopen, or route a finding into exception governance | Detail context surface | Does this finding need lifecycle closure, reopening, or governed exception handling? | Finding status, severity, governance posture, queue availability | Related navigation and deeper evidence | lifecycle, governance posture, severity | TenantPilot only | Close, Reopen, Request exception | Close is lifecycle-significant but not equivalent to tenant- or run-level destructive action |
|
||||
| TenantlessOperationRunViewer | Workspace operator | Inspect run state and understand whether follow-up exists | Monitoring detail viewer | What happened, what scope does it affect, and is any further action warranted? | Run identity, scope, outcome, freshness, follow-up availability | Related links, restore continuation, detailed failure reasons | execution outcome, freshness, lifecycle attention | Must remain explicit when a follow-up mutation is exposed | Refresh, Open related context | None by default on this surface |
|
||||
| System ViewRun | Platform operator | Retry, cancel, or mark a run investigated | Platform decision surface | Which intervention is justified for this run, and how serious is it? | Run identity, current outcome, action availability, investigation need | Runbooks and related operational context | execution outcome, retryability, cancellation state | Must be explicit per action before execution | Retry, Mark investigated | Cancel |
|
||||
| ViewTenant / EditTenant | Workspace operator | Archive or restore a tenant lifecycle state | Lifecycle detail/edit surface | Should this tenant remain active, or should its lifecycle state change formally? | Current lifecycle state and currently allowed lifecycle action | Supporting setup or external reference context | lifecycle readiness | TenantPilot only | Restore | Archive |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: yes
|
||||
- **New cross-domain UI framework/taxonomy?**: yes
|
||||
- **Current operator problem**: Operators currently infer governance severity from local page conventions instead of one stable product rule, which weakens safety and audit clarity.
|
||||
- **Existing structure is insufficient because**: Surface-layout rules alone do not determine confirm depth, reason obligation, danger semantics, or canonical verb choice across panels.
|
||||
- **Narrowest correct implementation**: Introduce one narrow derived governance-action catalog, friction classes, reason rules, a vocabulary canon, and documented exceptions. Do not add new workflow states, new persistence, or a generic execution framework.
|
||||
- **Ownership cost**: Ongoing review of new governance actions against the matrix, lightweight documentation upkeep for exceptions, and focused regression tests.
|
||||
- **Alternative intentionally rejected**: Purely local page cleanup was rejected because it would not stop the same governance family from drifting again on another surface.
|
||||
- **Release truth**: current-release operator safety and semantic consistency
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Make a Formal Exception Decision Safely (Priority: P1)
|
||||
|
||||
As a workspace approver, I want approving, rejecting, renewing, and revoking exception requests to use predictable friction and vocabulary so I can understand the seriousness of the action before I commit it.
|
||||
|
||||
**Why this priority**: Exception approval and exception lifecycle maintenance are the clearest governance decisions already spread across queue and detail surfaces.
|
||||
|
||||
**Independent Test**: This can be tested by reviewing the queue and exception detail surfaces alone and confirming that the same action family keeps the same confirm depth, reason expectation, and wording on both surfaces.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a pending exception request on Finding Exceptions Queue, **When** the operator chooses approve or reject, **Then** the modal language, reason handling, and severity cues match the shared rule for formal exception decisions.
|
||||
2. **Given** an active exception on ViewFindingException, **When** the operator chooses renew or revoke, **Then** the page uses the same exception-family vocabulary and the stronger lifecycle action is clearly separated from the lighter one.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Distinguish Governance Lifecycle from Technical Refresh (Priority: P1)
|
||||
|
||||
As a tenant reviewer, I want publication, archival, refresh, and evidence expiry actions to feel semantically distinct so I do not confuse a technical refresh with a formal governance decision.
|
||||
|
||||
**Why this priority**: Review and evidence surfaces already mix technical and governance-adjacent lifecycle actions, making them the highest-value place to harden semantics after exception handling.
|
||||
|
||||
**Independent Test**: This can be tested by reviewing ViewTenantReview and ViewEvidenceSnapshot without changing any other page and verifying that publish, archive, and expire semantics are clearly distinct from export or refresh.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mutable tenant review, **When** the operator compares Publish review, Export executive pack, and Archive review, **Then** publication and archival read as governance lifecycle steps while export remains clearly non-governance.
|
||||
2. **Given** an evidence snapshot that can still be expired, **When** the operator compares Refresh evidence and Expire snapshot, **Then** refresh reads as a lower-friction operational action and expiry reads as a governed lifecycle invalidation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Intervene on Runs with Calibrated Severity (Priority: P2)
|
||||
|
||||
As a platform operator, I want retry, cancel, and mark investigated to communicate different levels of seriousness so I can choose the right intervention without over- or under-reacting.
|
||||
|
||||
**Why this priority**: Run triage is a high-impact area where cancel and retry should never look like sibling actions with equivalent weight.
|
||||
|
||||
**Independent Test**: This can be tested entirely on run viewers by verifying that retry remains lighter, mark investigated captures rationale, and cancel is clearly the strongest action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a system run that can be retried or cancelled, **When** the header renders, **Then** Retry and Cancel are visibly differentiated by friction class and danger semantics.
|
||||
2. **Given** a run that needs explanation but not cancellation, **When** the operator marks it investigated, **Then** a reason is required and the action does not impersonate either retry or cancel semantics.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Keep Tenant Lifecycle Clear Without Inflating All Actions (Priority: P3)
|
||||
|
||||
As a workspace operator, I want tenant archive and restore actions to be consistent across View and Edit surfaces without turning every lifecycle action into a maximal danger event.
|
||||
|
||||
**Why this priority**: Tenant lifecycle is an important medium-priority family that should be aligned once the highest-risk governance families are stabilized.
|
||||
|
||||
**Independent Test**: This can be tested by comparing ViewTenant and EditTenant and confirming that archive and restore keep the same vocabulary, confirm depth, and danger rules on both surfaces.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active tenant, **When** Archive is available on ViewTenant and EditTenant, **Then** the action uses the same wording, strong separation, and reason expectations on both surfaces.
|
||||
2. **Given** an archived tenant, **When** Restore is available, **Then** it remains clearly distinct from Archive and does not inherit unnecessary high-risk semantics.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- If the same action family appears on a queue, a record page, and a system page, the surface type must not silently change the friction class.
|
||||
- If a lower-risk operational action sits next to a formal governance action, the lower-risk action must not inherit danger styling or mandatory-reason burden just because of proximity.
|
||||
- If a legacy surface exposes only one side of a canonical pair, the visible action still has to use the project-wide verb and friction rule for that family.
|
||||
- If a structured form already captures required governance fields, the operator must still see a clear rationale prompt where the family rule requires explanation.
|
||||
- If a future direct risk-acceptance surface is introduced, it must inherit the same vocabulary and reason rules currently carried by finding exception governance.
|
||||
- If a surface does not genuinely own a governance mutation, it must not promote a navigation shortcut or export as if it were a formal decision action.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes operator-facing governance semantics on existing write actions only. It introduces no new Microsoft Graph contract, no new queued workflow, and no new persistence. Existing mutations keep their current preview, confirmation, audit, and tenant-isolation rules; any DB-only governance mutation that does not create an `OperationRun` must continue to emit the existing audit trail.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature deliberately adds only the smallest semantic layer needed now: friction classes, reason rules, a vocabulary canon, and documented exceptions. It does not add a generic action framework, new domain states, or a persisted matrix table.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing actions that already create or reuse `OperationRun` objects, such as review refresh, evidence refresh, verification, and run retry/cancel flows, keep the current Ops-UX contract: toast intent-only, progress in monitoring surfaces, terminal DB notification where applicable, and service-owned `OperationRun.status` / `OperationRun.outcome` transitions. Spec 194 harmonizes only semantics, not operation lifecycle mechanics.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected planes are tenant admin `/admin/t/{tenant}`, workspace admin `/admin`, and system `/system`. No cross-plane access is widened. Non-members or users lacking entitled scope remain `404`, members missing capability remain `403`, and every destructive-like action still requires confirmation plus server-side authorization through existing Policies, Gates, or capability-backed helpers. Regression coverage must include at least one positive and one negative authorization path across tenant, workspace, and system families touched by this spec.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This spec does not touch login handshakes or `/auth/*` routes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This spec does not introduce new badge domains. Danger, severity, and lifecycle emphasis must continue to derive from centralized badge or action semantics rather than page-local color language.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** UI changes remain on native Filament `Action`, `ActionGroup`, `ViewRecord`, `ListRecords`, and existing shared helpers such as `UiEnforcement`. The feature must avoid page-local button frameworks or ad-hoc border/color systems. Semantic emphasis is carried through action grouping, action color, confirmation depth, and copy, not custom markup.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Each governance family must preserve one domain-first verb across buttons, modal headings, required-reason prompts, notifications, run titles, and audit prose. The operator verb must describe the business effect, not an implementation step or storage concern.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The Decision-First Surface Role table above defines which surfaces are primary decision surfaces and which remain secondary context or evidence surfaces. The feature keeps one governance case decidable in one place and prevents secondary exports, navigation, or related links from competing with the actual decision moment.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The UI/UX Surface Classification and Operator Surface Contract tables above define one inspect model per surface, the likely next operator action, placement for secondary and destructive actions, canonical nouns, scope signals, and any exception rationale. No affected surface may quietly mix navigation, review context, and governance mutation as undifferentiated peers.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, mutation, selected-context actions, and dangerous actions must remain structurally separated. `ActionGroup` usage must be meaningful rather than a mixed catch-all, and any exception must be justified as a real workflow need, not a convenience shortcut.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first. Diagnostics and raw evidence remain secondary. Mutating actions must disclose mutation scope before execution where the underlying action affects more than local UI state, and the safe-execution pattern for stronger actions remains visible through wording and confirmation structure.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature introduces semantic rules without adding a new presenter stack or persisted mirror. Domain truth remains in existing models and services; the new cross-cutting layer only governs action classification, operator copy, and friction consistency. Tests must verify business-visible consequences, not an indirection layer.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is expected to remain satisfied on all affected surfaces. Each surface must keep exactly one primary inspect/open model, no redundant View actions should be added, no empty `ActionGroup` placeholders may be introduced, and destructive actions must continue to use `Action::make(...)->action(...)->requiresConfirmation()`. No exemption is planned beyond existing context-only related-link groupings on already classified surfaces.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** Existing screen layouts, infolists, empty states, and table affordances remain intact. Spec 194 changes action semantics and grouping only; it does not justify converting infolists into disabled forms, removing list filters, or weakening empty-state clarity.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Gleiches Risiko, gleiche Friction-Klasse**: Actions mit vergleichbarer Governance-Bedeutung verwenden dieselbe Grundklasse, unabhaengig von Surface oder Panel.
|
||||
2. **Danger ist semantisch, nicht dekorativ**: Danger signalisiert Risiko, Irreversibilitaet oder starke Wirkung und ist kein allgemeiner Mutations-Akzent.
|
||||
3. **Reason-Capture ist bewusst abgestuft**: Nicht jede Mutation braucht eine Begruendung, aber formale Governance-Entscheidungen und starke Lifecycle-Eingriffe schon.
|
||||
4. **Operator-Vokabular ist systemweit lesbar**: Aehnliche Handlungen verwenden dieselben oder bewusst kompatiblen Verben.
|
||||
5. **Friction wird nicht lokal improvisiert**: Confirm, optionaler Grund, Pflichtgrund und Danger-Trennung werden nicht pro Surface neu erfunden.
|
||||
6. **Cross-cutting schlaegt lokale Eleganz**: Lokale Convenience rechtfertigt keine stille Governance-Ausnahme.
|
||||
|
||||
### Friction Taxonomy
|
||||
|
||||
| Klasse | Bedeutung | Bestaetigung | Reason-Capture | Danger-Semantik | Typische Beispiele |
|
||||
|---|---|---|---|---|---|
|
||||
| F0 | Informational / Low Impact | keine zusaetzliche Friction | kein Grund | kein Danger | Open related record, Open approval queue, Export executive pack, Show all operations |
|
||||
| F1 | Confirmed Operational Action | Bestaetigung erforderlich | kein Grund oder optional | nur wenn die Wirkung real riskant oder schwer reversibel ist | Refresh review, Refresh evidence, Retry run, Restore tenant |
|
||||
| F2 | Explained Governance Action | Bestaetigung erforderlich | Grund erforderlich oder strukturiert eindeutig gefuehrt | abhaengig von Wirkung, aber immer semantisch klar | Approve exception, Reject exception, Renew exception, Publish review, Expire snapshot, Close finding, Reopen finding, Mark investigated |
|
||||
| F3 | High-Risk / High-Impact Governance Action | strikte Bestaetigung | Grund zwingend | Danger zwingend und getrennte Platzierung | Revoke exception, Cancel run, Archive tenant, Archive review, kuenftige override- oder force-nahe Aktionen |
|
||||
|
||||
Eine action darf nur mit dokumentierter Ausnahme von ihrer Standardklasse abweichen.
|
||||
|
||||
### Reason Capture Rules
|
||||
|
||||
- **RL0 - none**: F0 actions verlangen keinen Grund und duerfen keine kuenstliche Governance-Frage erzeugen.
|
||||
- **RL1 - optional**: F1 actions duerfen einen optionalen Grund erlauben, wenn zusaetzlicher Kontext hilfreich ist, duerfen ihn aber nicht ohne dokumentierten Sonderfall verpflichtend machen.
|
||||
- **Current-release F1 decision**: In dieser Release-Stufe verwenden `Refresh evidence`, `Retry`, und `Restore` trotz bestaetigter Ausfuehrung keinen zusaetzlichen Freitext-Grund. Optionale F1-Begruendung bleibt ausserhalb von Spec 194, bis ein dokumentierter Sonderfall sie wirklich braucht.
|
||||
- **RL2 - required**: F2 und F3 actions muessen eine explizite Begruendung oder eine gleichwertig strukturierte formale Erklaerung erfassen.
|
||||
- **Audit propagation**: Jede verpflichtende oder eingegebene Begruendung muss in Audit-Prosa, Lifecycle-Historie oder Operation-Kontext wiederauffindbar sein, sofern die zugrunde liegende action heute auditierbar ist.
|
||||
|
||||
### Vocabulary Canon
|
||||
|
||||
- Formale Entscheidungs-Paare verwenden **Approve / Reject**.
|
||||
- Exception-Lifecycle verwendet **Renew exception / Revoke exception**.
|
||||
- Risk-acceptance-Semantik verwendet, sobald sie direkt sichtbar ist, **Accept risk / Renew acceptance / Revoke acceptance**. Bis dahin tragen Finding Exception Surfaces diese Semantik stellvertretend.
|
||||
- Review-Lifecycle verwendet **Publish review / Archive review / Create next review**.
|
||||
- Evidence verwendet **Refresh evidence** fuer technische Regeneration und **Expire snapshot** fuer formale Invalidierung. **Publish / Expire** bleibt fuer einen spaeteren echten Veroeffentlichungs-Lifecycle reserviert und darf nicht als Synonym fuer technischen Refresh missbraucht werden.
|
||||
- Finding-Lifecycle verwendet **Close / Reopen**.
|
||||
- Tenant-Lifecycle verwendet **Archive / Restore**.
|
||||
- Run-Triage verwendet **Retry / Cancel / Mark investigated**.
|
||||
- Euphemismen oder unnoetig wechselnde Synonyme wie `Dismiss`, `Release`, `Reset`, oder `Deactivate` sind fuer diese Familien nur mit dokumentierter Ausnahme zulaessig.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-194-001 Governance inventory**: Alle in Scope liegenden governance actions muessen in einer projektweiten inventory- und family-Sicht erfasst werden.
|
||||
- **FR-194-002 Exact classification**: Jede inventarisierte governance action muss genau einer Friction-Klasse F0, F1, F2 oder F3 zugeordnet werden.
|
||||
- **FR-194-003 Family-first consistency**: Gleichartige actions muessen in action families gebuendelt werden, damit die Klasse nicht still pro Surface neu definiert wird.
|
||||
- **FR-194-004 F0 rule**: F0 actions duerfen weder Pflichtbestaetigung noch reason-capture noch danger-styling verwenden.
|
||||
- **FR-194-005 F1 rule**: F1 actions verlangen Bestaetigung, aber standardmaessig keinen Pflichtgrund. Danger ist nur zulaessig, wenn die fachliche Wirkung tatsaechlich erhoehtes Risiko traegt.
|
||||
- **FR-194-006 F2 rule**: F2 actions verlangen Bestaetigung und explizite Erklaerung. Die Operator-Entscheidung muss spaeter nachvollziehbar bleiben.
|
||||
- **FR-194-007 F3 rule**: F3 actions verlangen Bestaetigung, Pflichtgrund, danger-styling und eine von Standardaktionen getrennte Platzierung.
|
||||
- **FR-194-008 Reason propagation**: Fuer F2- und F3-actions muss die Begruendung im bestehenden Audit- oder Lifecycle-Nachweis wiederauftauchen. F1 darf keine stille Pflichtbegruendung einfuehren.
|
||||
- **FR-194-009 Danger discipline**: Danger darf nicht inflationaer auf gewoehnliche Mutationen ausgedehnt werden. Hochwirksame und leichte Zustandsaenderungen muessen klar unterscheidbar bleiben.
|
||||
- **FR-194-010 Navigation separation**: Navigation, Export, reine Kontextwechsel und harmlose registry-actions muessen von governance actions semantisch getrennt bleiben.
|
||||
- **FR-194-011 Exception decision family**: `Approve exception`, `Reject exception`, `Renew exception`, und `Revoke exception` muessen als gemeinsame governance-family mit klaren Standardklassen und Gruenden behandelt werden.
|
||||
- **FR-194-012 Review lifecycle family**: `Publish review`, `Archive review`, und `Create next review` muessen semantisch mit der review-lifecycle-Logik abgestimmt sein. `Export executive pack` bleibt ausdruecklich ausserhalb dieser governance-friction.
|
||||
- **FR-194-013 Evidence lifecycle family**: Evidence lifecycle actions muessen zwischen technischem refresh und formaler invalidierung unterscheiden. `Refresh evidence` darf nicht wie `Expire snapshot` behandelt werden.
|
||||
- **FR-194-014 Run triage family**: `Retry`, `Cancel`, und `Mark investigated` muessen projektweit als klar unterscheidbare run-triage-family behandelt werden, unabhaengig davon, auf welcher run-surface sie auftauchen.
|
||||
- **FR-194-015 Finding lifecycle family**: `Close` und `Reopen` auf findings muessen dasselbe wording- und reason-Modell verwenden, auch wenn sie als header-, row-, oder bulk-action angeboten werden.
|
||||
- **FR-194-016 Tenant lifecycle family**: `Archive` und `Restore` auf ViewTenant und EditTenant muessen dieselbe confirm-, reason-, und danger-Logik verwenden.
|
||||
- **FR-194-017 Risk acceptance continuity**: Wo risk-acceptance-Semantik direkt oder indirekt sichtbar wird, darf sie keine konkurrierende lokale Vokabelfamilie erzeugen.
|
||||
- **FR-194-018 Mutation scope disclosure**: Jede F2- oder F3-action muss den tatsaechlichen Wirkungsscope vor der Ausfuehrung in verstaendlicher Operator-Sprache kommunizieren.
|
||||
- **FR-194-019 Copy alignment**: Button-Labels, modal-heading, modal-body, success-notification, audit-prosa und run-titel derselben family muessen dieselbe domain-Sprache verwenden.
|
||||
- **FR-194-020 No silent exceptions**: Jede bewusste Abweichung von Standardklasse, reason-Regel oder Vokabular muss als dokumentierter Sonderfall markiert sein.
|
||||
- **FR-194-021 Regression gate**: Neue governance actions duerfen nicht ohne Eintrag in die gemeinsame friction- und vocabulary-Matrix, dokumentierten reason-level und exception-status eingefuehrt werden.
|
||||
- **FR-194-022 Verification coverage**: Browser smoke checks und gezielte page- oder action-Tests muessen die high-priority-families und den regression gate abdecken.
|
||||
- **FR-194-023 Authorization continuity**: Keine alignment-Massnahme darf bestehende 404- oder 403-Semantik, capability checks oder deny-as-not-found-Verhalten veraendern.
|
||||
- **FR-194-024 Destructive action safety**: Alle destruktiven oder destruktiv wirkenden governance actions muessen weiterhin ueber bestaetigte Filament execution-actions laufen und serverseitig autorisiert bleiben.
|
||||
|
||||
## Governance Action Matrix
|
||||
|
||||
| Priority | Action | Primary Surfaces | Standard Friction | Reason Level | Danger Expectation | Vocabulary Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| High | Approve exception | Finding Exceptions Queue | F2 | required | no by default | Approve exception |
|
||||
| High | Reject exception | Finding Exceptions Queue | F2 | required | visually distinct from approval, but not automatically F3 | Reject exception |
|
||||
| High | Renew exception | ViewFindingException, related finding actions | F2 | required | no by default | Renew exception |
|
||||
| High | Revoke exception | ViewFindingException, related finding actions | F3 | required | required | Revoke exception |
|
||||
| High | Publish review | ViewTenantReview | F2 | required | no by default | Publish review |
|
||||
| High | Archive review | ViewTenantReview | F3 | required | required | Archive review |
|
||||
| High | Refresh evidence | ViewEvidenceSnapshot | F1 | none | no | Refresh evidence |
|
||||
| High | Expire snapshot | ViewEvidenceSnapshot, ListEvidenceSnapshots | F2 | required | required | Expire snapshot |
|
||||
| High | Retry run | System ViewRun and any future triage-owned admin run surface | F1 | none | no | Retry |
|
||||
| High | Mark investigated | System ViewRun and any future triage-owned admin run surface | F2 | required | no by default | Mark investigated |
|
||||
| High | Cancel run | System ViewRun and any future triage-owned admin run surface | F3 | required | required | Cancel |
|
||||
| Medium | Close finding | ViewFinding, Finding resource actions | F2 | required | no by default | Close |
|
||||
| Medium | Reopen finding | ViewFinding, Finding resource actions | F2 | required | no by default | Reopen |
|
||||
| Medium | Archive tenant | ViewTenant, EditTenant | F3 | required | required | Archive |
|
||||
| Medium | Restore tenant | ViewTenant, EditTenant | F1 | none | no | Restore |
|
||||
| Low / no-op | Export executive pack | ViewTenantReview | F0 | none | no | Export executive pack |
|
||||
| Low / no-op | Open queue, open related, show all, close details | Queue, detail, and monitoring surfaces | F0 | none | no | Use explicit navigation verbs only |
|
||||
|
||||
## Target Outcomes by Key Surface
|
||||
|
||||
1. **Finding Exceptions Queue**: Approval and rejection stop feeling like ad-hoc queue buttons and become one clearly governed decision family.
|
||||
2. **ViewFindingException**: Renewal and revocation read as lifecycle decisions with calibrated differences in severity.
|
||||
3. **ViewEvidenceSnapshot**: Refresh and expiry no longer blur together as generic snapshot mutations.
|
||||
4. **ViewTenantReview**: Publish, export, create-next, and archive become semantically distinct rather than one mixed lifecycle strip.
|
||||
5. **TenantlessOperationRunViewer / System ViewRun**: Run context stays separate from real intervention, while Retry, Cancel, and Mark investigated become legibly different actions wherever triage exists.
|
||||
6. **ViewTenant / EditTenant**: Archive and Restore stop drifting between view and edit variants and carry one shared tenant-lifecycle meaning.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Creating new governance states, enums, or persisted workflows beyond the friction and reason rules themselves
|
||||
- Rewriting header placement rules already governed by Spec 192 and Spec 193
|
||||
- Changing dispatch, provider-start, or preflight behavior for operations
|
||||
- Replacing existing audit infrastructure with a new audit domain
|
||||
- Renaming the product or introducing broad copy churn outside in-scope governance families
|
||||
- Forcing calm non-governance surfaces into a new friction model they do not need
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds or modifies any Filament Resource, RelationManager, or Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive, RBAC gating, whether the mutation writes an audit log, and any exemption or exception used.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | app/Filament/Pages/Monitoring/FindingExceptionsQueue.php | Scope, return, clear filters, tenant register, Selected context, Review selected | `Inspect exception` slide-over stays the only inspect affordance | `Inspect exception` | none | `Clear filters` | `Approve exception`, `Reject exception`, `Close details`, `Open tenant detail`, `Open finding` | n/a | yes | Review actions move to one governed exception family; Action Surface Contract remains satisfied |
|
||||
| ViewFindingException | app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php | n/a | n/a | n/a | n/a | n/a | `Renew exception`, `Revoke exception` | n/a | yes | Renew is F2, revoke is F3; no redundant View action |
|
||||
| ViewFinding | app/Filament/Resources/FindingResource/Pages/ViewFinding.php | Back to origin, related record, open approval queue, Actions group | Canonical record view page | none | Existing finding bulk actions remain family-aligned, not redefined here | n/a | `Close`, `Reopen`, `Request exception`, plus existing workflow actions | n/a | yes | `Request exception` remains navigation into exception governance, not a synonym for approval |
|
||||
| Evidence snapshots index + detail | app/Filament/Resources/EvidenceSnapshotResource.php and app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php | Index keeps inspection primary; detail keeps `Refresh evidence` and `Expire snapshot` | `recordUrl()` clickable row on index | `Expire snapshot` under More only | none | `Create first snapshot` remains non-governance create CTA | `Refresh evidence`, `Expire snapshot` | n/a | yes | Refresh stays F1; Expire stays governed and danger-separated |
|
||||
| ViewTenantReview | app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php | Primary lifecycle action, `More`, and `Danger` groups | Canonical record view page | none | none | n/a | `Refresh review`, `Publish review`, `Export executive pack`, `Create next review`, `Archive review` | n/a | yes | Publish and archive must not inherit export semantics |
|
||||
| TenantlessOperationRunViewer | app/Filament/Pages/Operations/TenantlessOperationRunViewer.php | Scope, back, show all, refresh, Open, optional resume-capture follow-up | Canonical tenantless run detail page | none | none | n/a | Context-first follow-up actions only | n/a | existing run or audit trail | No new destructive triage is added here unless the surface genuinely owns it |
|
||||
| System ViewRun | app/Filament/System/Pages/Ops/ViewRun.php | Show all operations, go to runbooks, retry, cancel, mark investigated | Canonical system run detail page | none | none | n/a | `Retry`, `Cancel`, `Mark investigated` | n/a | yes | Retry F1, Mark investigated F2, Cancel F3 |
|
||||
| ViewTenant / EditTenant | app/Filament/Resources/TenantResource/Pages/ViewTenant.php and app/Filament/Resources/TenantResource/Pages/EditTenant.php | View keeps external, setup, and lifecycle groups; edit keeps lifecycle group | Canonical workspace tenant view or edit page | none | none | n/a | `Archive`, `Restore` | Edit page keeps standard save and cancel | yes | Archive and restore must remain one consistent tenant-lifecycle family across both surfaces |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Governance Action Family**: A named group of semantically equivalent operator actions, defined by canonical verb, standard friction class, reason level, danger expectation, mutation scope wording, and allowed exceptions.
|
||||
- **Friction Class Assignment**: The explicit mapping of one operator action to F0, F1, F2, or F3 together with the applicable reason rule and danger rule.
|
||||
- **Documented Exception**: A reviewed deviation from the standard family rule, including the affected surface, the rationale, and why it cannot safely follow the default pattern.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- There is currently no standalone direct risk-acceptance page; finding exception governance carries the present risk-acceptance semantics.
|
||||
- Workspace approval of finding exceptions remains the canonical entry point for formal exception decisions.
|
||||
- TenantlessOperationRunViewer may stay context-first if it does not genuinely own retry, cancel, or investigated mutations.
|
||||
- Existing audit and lifecycle services already persist or expose the necessary operator rationale where these actions are currently governed.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 192 for record-page header and navigation discipline
|
||||
- Spec 193 for monitoring surface action hierarchy and workbench semantics
|
||||
- Existing capability registry, `UiEnforcement`, Policies, and deny-as-not-found semantics
|
||||
- Existing audit logging and operation triage or lifecycle services for review, evidence, findings, runs, and tenant lifecycle actions
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Zu viel Friction fuer harmlose actions**: The product becomes slower than necessary. Mitigation: strict F0 and F1 separation and explicit non-goals.
|
||||
2. **Zu wenig Friction fuer hochwirksame actions**: High-risk governance intervention stays undertoned. Mitigation: F3 defaults are explicit and exceptions must be documented.
|
||||
3. **Vocabulary wird kosmetisch statt semantisch behandelt**: Labels change but severity logic does not. Mitigation: every vocabulary decision is coupled to family, friction, and reason rules.
|
||||
4. **Cross-surface drift bleibt bestehen**: The same action behaves differently on queue, detail, and system surfaces. Mitigation: regression gate and shared family matrix.
|
||||
|
||||
## Recommended Sequencing
|
||||
|
||||
1. Inventory all in-scope governance actions and assign them to action families.
|
||||
2. Align exception, review, and evidence families first because they contain the densest formal governance decisions.
|
||||
3. Align run triage and tenant lifecycle families next, preserving panel-specific authorization but removing semantic drift.
|
||||
4. Add regression protection and browser smoke coverage after the core families are normalized.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of in-scope governance actions on the remediated surfaces are listed in the shared inventory with action family, friction class, reason level, danger expectation, and exception status.
|
||||
- **SC-002**: 100% of high-priority action families use one consistent confirm depth and one consistent vocabulary set across every covered surface where that family appears.
|
||||
- **SC-003**: Browser smoke review of all remediated surfaces shows that every F3 action is visually and semantically distinct from nearby F0 or F1 actions, with no undocumented exceptions.
|
||||
- **SC-004**: Positive and negative authorization checks continue to pass for at least one tenant-plane, one workspace-plane, and one system-plane governance family after the alignment.
|
||||
242
specs/194-governance-friction-hardening/tasks.md
Normal file
242
specs/194-governance-friction-hardening/tasks.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Tasks: Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
**Input**: Design documents from `/specs/194-governance-friction-hardening/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-action-semantics.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior on existing Filament v5 / Livewire v4 operator surfaces, so Pest unit, feature, RBAC, audit, and browser smoke coverage must be added or extended.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3 -> US4`, with `US1` as the MVP cut after the shared catalog and guard foundation is in place.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the dedicated support and test entry points for Spec 194 without changing runtime behavior yet.
|
||||
|
||||
- [X] T001 Create the governance action support scaffolds in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`, `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`, `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php`, and `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
|
||||
- [X] T002 [P] Create the Spec 194 unit and guard test scaffolds in `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php` and `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
|
||||
- [X] T003 [P] Create the browser smoke scaffold in `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
|
||||
|
||||
**Checkpoint**: The new support namespace and dedicated Spec 194 test entry points exist, so shared rule work can begin without mixing this slice into unrelated suites.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Codify the shared governance semantics inventory and regression gate that every story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T004 [P] Add catalog invariant, deterministic F1 reason-policy, and documented-deviation coverage in `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php`
|
||||
- [X] T005 [P] Add family, surface, vocabulary, indirect risk-acceptance continuity, and exception regression coverage in `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
|
||||
- [X] T006 [P] Implement the friction and reason enums in `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php` and `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
|
||||
- [X] T007 [P] Implement the shared rule value object in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`
|
||||
- [X] T008 Implement the canonical family inventory, surface bindings, deterministic F1 reason defaults, indirect risk-acceptance continuity, and documented deviations in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`
|
||||
|
||||
**Checkpoint**: The repo can enumerate in-scope governance actions, classify them, and fail CI on undocumented semantic drift before any page-level refactor starts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Make a Formal Exception Decision Safely (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make queue and detail exception decisions use one predictable friction, reason, and vocabulary contract.
|
||||
|
||||
**Independent Test**: Open the finding exceptions queue and one finding exception detail page, then confirm approve, reject, renew, and revoke follow the shared exception-family semantics without changing any review, evidence, run, or tenant lifecycle pages.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T009 [P] [US1] Extend queue selection-state, modal friction, and reason-prompt coverage in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php` and `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`
|
||||
- [X] T010 [P] [US1] Extend exception lifecycle, authorization, header-discipline, audit, and indirect risk-acceptance vocabulary continuity coverage in `apps/platform/tests/Feature/Findings/FindingExceptionWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionRenewalTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionRevocationTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionAuthorizationTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionPolicyTest.php`, and `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Align exception-family reason propagation, canonical audit verbs, and success copy in `apps/platform/app/Services/Findings/FindingExceptionService.php`
|
||||
- [X] T012 [US1] Refactor queue decision actions to consume catalog rules in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- [X] T013 [US1] Refactor exception detail lifecycle actions to consume catalog rules and keep revoke as the separated F3 action in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
|
||||
**Checkpoint**: Exception governance is independently functional and consistent across queue and detail surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Distinguish Governance Lifecycle from Technical Refresh (Priority: P1)
|
||||
|
||||
**Goal**: Make review publication or archival and evidence refresh or expiry read as clearly different action families instead of peer mutations.
|
||||
|
||||
**Independent Test**: Open a tenant review detail page and an evidence snapshot detail page, then confirm publish and archive are distinct from export, while refresh remains lighter than expire, without changing run or tenant lifecycle surfaces.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T014 [P] [US2] Extend review lifecycle, export separation, authorization, and audit coverage in `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, and `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`
|
||||
- [X] T015 [P] [US2] Extend evidence refresh-versus-expire, authorization, and audit coverage in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [P] [US2] Align review lifecycle reason propagation, audit prose, and notification wording in `apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php`
|
||||
- [X] T017 [US2] Refactor review detail actions so publish and archive follow the catalog while export and create-next stay outside governance friction in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T018 [P] [US2] Align evidence lifecycle reason propagation, audit prose, and notification wording in `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
||||
- [X] T019 [US2] Refactor evidence list and detail actions so refresh remains F1 and expire follows governed lifecycle rules in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
|
||||
**Checkpoint**: Review and evidence lifecycle actions are independently functional and semantically distinct from technical refresh or export actions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Intervene on Runs with Calibrated Severity (Priority: P2)
|
||||
|
||||
**Goal**: Make retry, mark investigated, and cancel communicate different seriousness on triage-owned run surfaces while preserving calm monitoring context elsewhere.
|
||||
|
||||
**Independent Test**: Open the system run detail page and the tenantless run viewer, then confirm retry stays lighter than mark investigated and cancel, while the tenantless viewer does not promote undocumented triage actions.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T020 [P] [US3] Extend system run triage severity, reason, and authorization coverage in `apps/platform/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php` and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
- [X] T021 [P] [US3] Extend calm-surface and no-triage-regression coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T022 [US3] Align run-triage reason propagation, audit verbs, and operator copy in `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
- [X] T023 [US3] Refactor the system run header actions so retry, mark investigated, and cancel use calibrated friction, danger, and placement from the catalog in `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`
|
||||
- [X] T024 [US3] Review the tenantless viewer and keep it context-first unless a documented governance binding is required in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
|
||||
**Checkpoint**: Run triage is independently functional on the system surface without leaking high-friction semantics onto the calm tenantless viewer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Keep Tenant Lifecycle Clear Without Inflating All Actions (Priority: P3)
|
||||
|
||||
**Goal**: Keep archive and restore consistent across tenant view and edit surfaces without over-hardening lower-risk lifecycle moves.
|
||||
|
||||
**Independent Test**: Open the tenant view and edit surfaces for active and archived tenants, then confirm archive and restore use the same vocabulary, confirm depth, and danger rules on both pages.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T025 [P] [US4] Extend tenant lifecycle visibility, naming, and authorization coverage in `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `apps/platform/tests/Feature/Rbac/TenantLifecycleActionNamingTest.php`, `apps/platform/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, and `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
|
||||
- [X] T026 [P] [US4] Extend tenant lifecycle audit and cross-surface presentation coverage in `apps/platform/tests/Feature/Audit/TenantLifecycleAuditLogTest.php` and `apps/platform/tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T027 [US4] Refactor tenant lifecycle action definitions to bind archive and restore to the shared catalog in `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T028 [US4] Refactor tenant view and edit page lifecycle actions so archive and restore stay aligned across both surfaces in `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||
|
||||
**Checkpoint**: Tenant lifecycle actions are independently functional and consistent across both workspace tenant surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish the remaining finding-lifecycle alignment, lock in browser proof, and run focused verification.
|
||||
|
||||
- [X] T029 [P] Add finding lifecycle header-, row-, and bulk-action, navigation-separation, destructive-confirmation, authorization, and audit coverage in `apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowUiEnforcementTest.php`, `apps/platform/tests/Feature/Findings/FindingRbacTest.php`, and `apps/platform/tests/Feature/Findings/FindingAuditLogTest.php`
|
||||
- [X] T030 [P] Add cross-surface browser smoke coverage for exception, review, evidence, run, and tenant lifecycle semantics in `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
|
||||
- [X] T031 Refactor finding lifecycle header, row, and bulk actions so close and reopen follow the catalog while request-exception stays navigation into governance in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||
- [X] T032 Review mutation-scope wording, explicit confirmation copy for destructive families, canonical labels, notifications, indirect risk-acceptance continuity, and documented deviations in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T033 Run the focused Sail verification workflow from `specs/194-governance-friction-hardening/quickstart.md` against the changed unit, feature, RBAC, audit, and browser tests in `apps/platform/tests/`
|
||||
- [X] T034 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for the changed files in `apps/platform/app/` and `apps/platform/tests/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP cut.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can run in parallel with US1 after the shared catalog is stable.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion; can run in parallel with US1 or US2 once the run family keys exist in the catalog.
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational completion; can run in parallel with US2 or US3 because it touches separate tenant lifecycle files.
|
||||
- **Polish (Phase 7)**: Depends on the desired user stories being complete; it closes the remaining finding lifecycle requirement and final regression proof.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: No dependencies beyond Foundational, but it reuses the catalog and guard patterns established for US1.
|
||||
- **US3**: No dependencies beyond Foundational, but it reuses the same reason and danger semantics contract.
|
||||
- **US4**: No dependencies beyond Foundational; it consumes the shared lifecycle rules after the catalog exists.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation.
|
||||
- Update the owning service before finalizing the matching page actions when reason propagation or audit wording changes.
|
||||
- Keep each story independently shippable before widening the slice.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T004`, `T005`, `T006`, and `T007` can run in parallel before `T008`.
|
||||
- Within US1, `T009` and `T010` can run in parallel.
|
||||
- Within US2, `T014` and `T015` can run in parallel, and `T016` and `T018` can run in parallel.
|
||||
- Within US3, `T020` and `T021` can run in parallel.
|
||||
- Within US4, `T025` and `T026` can run in parallel.
|
||||
- Within Phase 7, `T029` and `T030` can run in parallel once all page-level story work is complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T009 Extend queue selection-state, modal friction, and reason-prompt coverage
|
||||
T010 Extend exception lifecycle, authorization, header-discipline, and audit coverage
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel story work for US2
|
||||
T014 Extend tenant review lifecycle, export separation, authorization, and audit coverage
|
||||
T015 Extend evidence refresh-versus-expire, authorization, and audit coverage
|
||||
T016 Align review lifecycle reason propagation, audit prose, and notification wording
|
||||
T018 Align evidence lifecycle reason propagation, audit prose, and notification wording
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T020 Extend system run triage severity, reason, and authorization coverage
|
||||
T021 Extend calm-surface and no-triage-regression coverage
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US4
|
||||
T025 Extend tenant lifecycle visibility, naming, and authorization coverage
|
||||
T026 Extend tenant lifecycle audit and cross-surface presentation coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational catalog and guard work.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the exception-family behavior through the focused US1 tests.
|
||||
5. Stop and review the shared catalog shape before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to establish the first remediated governance family.
|
||||
2. Add US2 to normalize review and evidence lifecycle semantics.
|
||||
3. Add US3 to calibrate run triage without destabilizing calm monitoring surfaces.
|
||||
4. Add US4 to finish tenant lifecycle alignment.
|
||||
5. Finish with Phase 7 to align finding lifecycle, add browser proof, and run focused verification.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor completes Setup and Foundational tasks.
|
||||
2. After Foundation is green:
|
||||
- Contributor A takes US1.
|
||||
- Contributor B takes US2.
|
||||
- Contributor C takes US3.
|
||||
- Contributor D takes US4.
|
||||
3. Merge back for Phase 7 finding-lifecycle alignment, browser smoke, and verification.
|
||||
Loading…
Reference in New Issue
Block a user