From 670c46deddcb906c1a0a9100411431ba75453de9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 7 May 2026 10:20:43 +0000 Subject: [PATCH 1/4] Spec 280: prepare workspace tenancy and environment routing cutover (#336) ## Summary - add the implementation-ready spec-prep artifacts for Spec 280: Filament Workspace Tenancy & Environment Routing Cutover - define the bounded scope, rollout constraints, route contract, and validation plan for the workspace-first routing cutover - update the generated Copilot agent context for the active feature branch ## Testing - not run; this branch adds spec-prep artifacts only and does not change application code ## Notes - no application runtime, database, or frontend code changes are included in this PR - target base branch requested: `platform-dev` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/336 --- .github/agents/copilot-instructions.md | 4 +- .../checklists/requirements.md | 70 +++ ...y-environment-routing.logical.openapi.yaml | 422 ++++++++++++++++++ .../data-model.md | 192 ++++++++ .../plan.md | 294 ++++++++++++ .../quickstart.md | 53 +++ .../research.md | 78 ++++ .../spec.md | 394 ++++++++++++++++ .../tasks.md | 221 +++++++++ 9 files changed, 1727 insertions(+), 1 deletion(-) create mode 100644 specs/280-workspace-tenancy-environment-routing/checklists/requirements.md create mode 100644 specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml create mode 100644 specs/280-workspace-tenancy-environment-routing/data-model.md create mode 100644 specs/280-workspace-tenancy-environment-routing/plan.md create mode 100644 specs/280-workspace-tenancy-environment-routing/quickstart.md create mode 100644 specs/280-workspace-tenancy-environment-routing/research.md create mode 100644 specs/280-workspace-tenancy-environment-routing/spec.md create mode 100644 specs/280-workspace-tenancy-environment-routing/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 7eee7a53..411e1815 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -276,6 +276,8 @@ ## Active Technologies - PostgreSQL via existing `users.preferred_locale`, existing workspace localization setting, existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, memberships, and `audit_logs`; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`; no new persistence planned (275-customer-facing-localization-adoption) - Markdown and YAML planning artifacts over PHP 8.4 / Laravel 12 source anchors + `spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, nearby Specs `270`, `275`, and `277`, and repo-real source anchors such as `OperationUxPresenter`, `InventoryKpiHeader`, `RecoveryReadiness`, `BaselineSnapshotPresenter`, `ReviewPackService`, and `TenantDashboardSummaryBuilder` (278-cross-domain-indicator-audit) - Repository files only; no database or runtime persistence changes (278-cross-domain-indicator-audit) +- PHP 8.4.15, Laravel 12.52 + Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing workspace and environment authorization/context helpers, existing Filament panel providers and page/resource action-surface contracts (280-workspace-tenancy-environment-routing) +- PostgreSQL, no new persistence or schema change in this slice (280-workspace-tenancy-environment-routing) - PHP 8.4.15 (feat/005-bulk-operations) @@ -310,9 +312,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 280-workspace-tenancy-environment-routing: Added PHP 8.4.15, Laravel 12.52 + Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing workspace and environment authorization/context helpers, existing Filament panel providers and page/resource action-surface contracts - 278-cross-domain-indicator-audit: Added Markdown and YAML planning artifacts over PHP 8.4 / Laravel 12 source anchors + `spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, nearby Specs `270`, `275`, and `277`, and repo-real source anchors such as `OperationUxPresenter`, `InventoryKpiHeader`, `RecoveryReadiness`, `BaselineSnapshotPresenter`, `ReviewPackService`, and `TenantDashboardSummaryBuilder` - 275-customer-facing-localization-adoption: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers -- 266-tenant-dashboard-productization-v1: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers ### Pre-production compatibility check diff --git a/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md b/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md new file mode 100644 index 00000000..74305831 --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md @@ -0,0 +1,70 @@ +# Specification Quality Checklist: Filament Workspace Tenancy & Environment Routing Cutover + +**Purpose**: Validate package completeness, boundedness, and readiness before implementation +**Created**: 2026-05-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] The package stays on reserved slot `280` and does not silently absorb Specs `281`-`287`. +- [x] The package remains product- and behavior-oriented instead of reading like a raw route diff. +- [x] The plan is grounded in verified repo seams: `AdminPanelProvider`, `TenantPanelProvider`, `ChooseWorkspace`, `ChooseTenant`, `ManagedTenantsLanding`, `WorkspaceOverview`, `TenantDashboard`, `Monitoring\Operations`, `WorkspaceRedirectResolver`, middleware, route classifiers, and shared navigation builders. +- [x] Workspace and managed-environment dashboards are explicitly reused rather than replaced. +- [x] No compatibility routes, redirects, aliases, or dual-panel end state remain in scope. + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`. +- [x] The package explicitly makes `Workspace` the only Filament tenant for operator routing. +- [x] The package explicitly removes `/admin/t/{environment}` and `/admin/tenants/{environment}/required-permissions` instead of preserving them as compatibility surfaces. +- [x] The package explicitly removes `/admin/w/{workspace}/managed-tenants` and `/admin/operations` plus `/admin/operations/{run}` instead of preserving them as compatibility surfaces. +- [x] The package aligns on the canonical workspace-first route family for workspace dashboard, environment chooser, environment dashboard, and operations hub. +- [x] The package aligns on the workspace-scoped environment chooser as the canonical public environment-selection surface and does not preserve a second public chooser route. +- [x] The package forces touched global-search surfaces to keep valid view/edit destinations or disable search in the same slice. +- [x] Dependencies, assumptions, risks, and out-of-scope boundaries are explicit. + +## Repo Truth Anchoring + +- [x] The package reflects that `TenantPanelProvider` currently uses `path('admin/t')` and `tenant(ManagedEnvironment::class, slugAttribute: 'slug')`. +- [x] The package reflects that `AdminPanelProvider` already owns `/admin`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceOverview`, `TenantRequiredPermissions`, and the current operations surface. +- [x] The package reflects that `ManagedTenantsLanding` already binds a `Workspace` and lists accessible managed environments. +- [x] The package reflects that `Monitoring\Operations` already carries `managed_environment_id` filter state and navigation context, so the cutover reuses the page rather than replacing it. +- [x] The package reflects that current middleware and page categorization still special-case `/admin/t` and `/admin/tenants/{environment}`. +- [x] The package reflects that `routes/web.php` still exposes the old chooser and operations families that 280 must retire. +- [x] The package keeps provider registration in `apps/platform/bootstrap/providers.php`. + +## Feature Readiness + +- [x] Filament v5 and Livewire v4 expectations remain explicit across the package. +- [x] Provider registration location, global-search handling, destructive-action requirements, and asset strategy are explicit in the plan. +- [x] Routing, chooser flow, breadcrumbs, current-context seams, operations-link semantics, and legacy-route removal are all covered. +- [x] Provider extraction, artifact retargeting, RBAC redesign, copy neutralization, and quality-gate work remain explicitly deferred to Specs `281`-`287`. +- [x] The package is implementation-ready without reopening Spec `279`. + +## Artifact Alignment + +- [x] `research.md` records the same routing, chooser, operations, and search decisions reflected in `plan.md`. +- [x] `data-model.md` models the same workspace dashboard, environment chooser, environment dashboard, operations, and searchable-destination contracts reflected in the plan and contract. +- [x] `quickstart.md` uses the same bounded reviewer flow and proof commands as `plan.md`. +- [x] `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` models the canonical workspace-first GET routes and explicit 404 legacy removals. +- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md` and cover routes, provider registration, and every retired public route family. + +## Test Governance + +- [x] Planned proof stays bounded to feature coverage, one browser smoke, and explicit grep/guard checks. +- [x] No heavy-governance or broad browser family is introduced. +- [x] Workspace-plus-managed-environment fixture/context cost is acknowledged instead of hidden. +- [x] Reviewer handoff includes explicit minimal validation commands for route ownership, tenancy change, and legacy-route removal. + +## Notes + +- Reviewed against `.specify/memory/constitution.md`, the Filament v5 documentation results captured for panel configuration, global search, and page/resource testing, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Tenants/TenantPageCategory.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/routes/web.php`, and `apps/platform/bootstrap/providers.php` on 2026-05-07. +- No application implementation was performed while preparing this package. + +## Review Outcome + +- **Outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Reason**: The package closes the temporary `/admin/t` shell using the existing workspace and environment surfaces, converges on one workspace-first route language, and leaves the deferred provider/artifact/RBAC/copy/quality-gate work explicitly to Specs `281`-`287`. +- **Workflow result**: Ready for implementation planning or execution as the second reserved cutover slice. \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml b/specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml new file mode 100644 index 00000000..da7103dd --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml @@ -0,0 +1,422 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin - Workspace Tenancy & Environment Routing Cutover (Conceptual) + version: 0.1.0 + description: | + Conceptual GET-route contract for Spec 280. + + This package removes the temporary `/admin/t/{environment}` public shell, + makes `Workspace` the only Filament tenant for operator admin routing, and + moves managed-environment work under workspace-first routes rooted at + `/admin/workspaces/{workspace}`. +servers: + - url: /admin +paths: + /: + get: + summary: Resolve the operator admin entrypoint + description: | + Direct `/admin` requests resolve to workspace selection or the active + workspace dashboard. `/admin` is not a second canonical managed- + environment dashboard route after this cutover. + responses: + '302': + description: Redirect to workspace selection or the active workspace dashboard + headers: + Location: + schema: + type: string + x-logical-outcomes: + - route: /admin/choose-workspace + when: no valid workspace context exists + - route: /admin/workspaces/{workspace} + when: a valid workspace context exists + /workspaces/{workspace}: + get: + summary: Open the canonical workspace dashboard + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + responses: + '200': + description: Workspace dashboard rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/WorkspaceDashboardView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + /workspaces/{workspace}/environments: + get: + summary: Open the canonical workspace-scoped environment chooser + description: | + Reuses the existing workspace-bound environment-selection surface and + replaces any need for a second public chooser route. + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + responses: + '200': + description: Environment chooser rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/EnvironmentChooserView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + /workspaces/{workspace}/environments/{environment}: + get: + summary: Open the canonical managed-environment dashboard + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + - $ref: '#/components/parameters/ManagedEnvironmentIdentifier' + responses: + '200': + description: Managed-environment dashboard rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/ManagedEnvironmentDashboardView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + managed_environment_membership: required + /workspaces/{workspace}/environments/{environment}/required-permissions: + get: + summary: Open required permissions under workspace-first environment routing + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + - $ref: '#/components/parameters/ManagedEnvironmentIdentifier' + responses: + '200': + description: Required permissions page rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/ManagedEnvironmentPageView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + managed_environment_membership: required + /workspaces/{workspace}/operations: + get: + summary: Open the canonical workspace operations hub + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + - $ref: '#/components/parameters/ManagedEnvironmentFilter' + - $ref: '#/components/parameters/TenantScope' + - $ref: '#/components/parameters/ActiveTab' + - $ref: '#/components/parameters/ProblemClass' + responses: + '200': + description: Workspace operations hub rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/WorkspaceOperationsView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + managed_environment_membership: conditional-when-filtered + /workspaces/{workspace}/operations/{run}: + get: + summary: Open the canonical operation run detail viewer + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + - $ref: '#/components/parameters/OperationRunIdentifier' + responses: + '200': + description: Operation run detail rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/OperationRunDetailView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-capability-rules: + workspace_membership: required + run_visibility: tenant-safe-within-workspace + /w/{workspace}/managed-tenants: + get: + summary: Legacy workspace-scoped managed-tenants chooser route removed + description: | + Spec 280 removes `/admin/w/{workspace}/managed-tenants` and replaces it + with the canonical workspace-first environment chooser at + `/admin/workspaces/{workspace}/environments`. No redirect or + compatibility alias survives. + parameters: + - $ref: '#/components/parameters/WorkspaceIdentifier' + responses: + '404': + $ref: '#/components/responses/NotFound' + x-removed-by-spec: 280 + /operations: + get: + summary: Legacy workspace operations collection route removed + description: | + Spec 280 removes `/admin/operations` in favor of + `/admin/workspaces/{workspace}/operations`. No redirect or compatibility + alias survives. + responses: + '404': + $ref: '#/components/responses/NotFound' + x-removed-by-spec: 280 + /operations/{run}: + get: + summary: Legacy workspace operations detail route removed + description: | + Spec 280 removes `/admin/operations/{run}` in favor of + `/admin/workspaces/{workspace}/operations/{run}`. No redirect or + compatibility alias survives. + parameters: + - $ref: '#/components/parameters/OperationRunIdentifier' + responses: + '404': + $ref: '#/components/responses/NotFound' + x-removed-by-spec: 280 + /t/{environment}: + get: + summary: Legacy environment panel route removed + description: | + Spec 280 removes the public `/admin/t/{environment}` route family. No + redirect or compatibility alias survives. + parameters: + - $ref: '#/components/parameters/ManagedEnvironmentIdentifier' + responses: + '404': + $ref: '#/components/responses/NotFound' + x-removed-by-spec: 280 + /tenants/{environment}/required-permissions: + get: + summary: Legacy required-permissions route removed + description: | + Spec 280 removes `/admin/tenants/{environment}/required-permissions` + and replaces it with the workspace-first environment route family. + parameters: + - $ref: '#/components/parameters/ManagedEnvironmentIdentifier' + responses: + '404': + $ref: '#/components/responses/NotFound' + x-removed-by-spec: 280 +components: + parameters: + WorkspaceIdentifier: + name: workspace + in: path + required: true + schema: + type: string + ManagedEnvironmentIdentifier: + name: environment + in: path + required: true + schema: + type: string + OperationRunIdentifier: + name: run + in: path + required: true + schema: + type: integer + ManagedEnvironmentFilter: + name: managed_environment_id + in: query + required: false + schema: + type: integer + description: Optional explicit environment prefilter inside the active workspace. + TenantScope: + name: tenant_scope + in: query + required: false + schema: + type: string + enum: [all] + ActiveTab: + name: activeTab + in: query + required: false + schema: + type: string + ProblemClass: + name: problemClass + in: query + required: false + schema: + type: string + responses: + Forbidden: + description: Actor is in scope but lacks the required capability on the workspace or managed environment. + NotFound: + description: Wrong workspace, wrong managed environment, removed legacy route, or non-member access is hidden as not found. + schemas: + WorkspaceSummary: + type: object + required: [id, name, slug] + properties: + id: + type: integer + name: + type: string + slug: + type: string + ManagedEnvironmentSummary: + type: object + required: [id, workspace_id, slug, name, lifecycle_status] + properties: + id: + type: integer + workspace_id: + type: integer + slug: + type: string + name: + type: string + lifecycle_status: + type: string + BreadcrumbSegment: + type: object + required: [label, url] + properties: + label: + type: string + url: + type: string + WorkspaceDashboardView: + type: object + required: [workspace, overview_payload, environment_chooser_url, operations_url] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + overview_payload: + type: object + additionalProperties: true + environment_chooser_url: + type: string + operations_url: + type: string + EnvironmentChooserItem: + type: object + required: [id, slug, name, lifecycle_status, open_url] + properties: + id: + type: integer + slug: + type: string + name: + type: string + lifecycle_status: + type: string + posture_hint: + type: string + nullable: true + open_url: + type: string + EnvironmentChooserView: + type: object + required: [workspace, environments] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + environments: + type: array + items: + $ref: '#/components/schemas/EnvironmentChooserItem' + switch_workspace_url: + type: string + nullable: true + ManagedEnvironmentDashboardView: + type: object + required: [workspace, managed_environment, breadcrumbs, operations_url] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + managed_environment: + $ref: '#/components/schemas/ManagedEnvironmentSummary' + breadcrumbs: + type: array + items: + $ref: '#/components/schemas/BreadcrumbSegment' + dashboard_summary: + type: object + additionalProperties: true + operations_url: + type: string + ManagedEnvironmentPageView: + type: object + required: [workspace, managed_environment, breadcrumbs, page_label, operations_url] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + managed_environment: + $ref: '#/components/schemas/ManagedEnvironmentSummary' + breadcrumbs: + type: array + items: + $ref: '#/components/schemas/BreadcrumbSegment' + page_label: + type: string + operations_url: + type: string + WorkspaceOperationsView: + type: object + required: [workspace] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + managed_environment_id: + type: integer + nullable: true + active_tab: + type: string + nullable: true + problem_class: + type: string + nullable: true + nav_context: + type: object + nullable: true + additionalProperties: true + OperationRunDetailView: + type: object + required: [workspace, run_id] + properties: + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + managed_environment: + $ref: '#/components/schemas/ManagedEnvironmentSummary' + nullable: true + run_id: + type: integer + back_link_url: + type: string + nullable: true \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/data-model.md b/specs/280-workspace-tenancy-environment-routing/data-model.md new file mode 100644 index 00000000..2bcfb07f --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/data-model.md @@ -0,0 +1,192 @@ +# Data Model: Filament Workspace Tenancy & Environment Routing Cutover + +**Date**: 2026-05-07 +**Branch**: `280-workspace-tenancy-environment-routing` + +## Overview + +This slice introduces no new persistence. It replaces a temporary routing and panel-tenancy shell with a workspace-first runtime contract built on existing `Workspace` and `ManagedEnvironment` truth. The data model for this package is therefore a set of derived route, view-model, and navigation contracts that implementation must keep aligned across pages, middleware, and shared link builders. + +## Persisted Truth Unchanged + +- `Workspace` remains the workspace-owned root context. +- `ManagedEnvironment` remains the environment-scoped managed target inside one workspace. +- No table ownership, foreign-key ownership, artifact ownership, or role/capability family changes are introduced in this slice. +- Provider registration remains in `apps/platform/bootstrap/providers.php`; no new provider or asset persistence is introduced. + +## Derived Runtime Contracts + +### 1. Workspace Admin Context + +**Persistence**: derived from route parameters plus `WorkspaceContext` session state +**Owner**: workspace-first admin shell + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace_id` | int | yes | Canonical workspace scope for the current request | +| `workspace_slug` | string | yes | Route-safe workspace identifier | +| `workspace_name` | string | yes | Operator-visible workspace label | +| `entry_mode` | string | yes | `chooser` or `dashboard` | +| `remembered_environment_id` | int | no | Derived only when the remembered environment belongs to the same workspace | + +**Rules**: + +- `/admin` resolves through this contract to either workspace selection or the canonical workspace dashboard. +- Non-member workspace access remains `404`. +- Switching workspaces invalidates any remembered environment that belongs to another workspace before any environment page renders. + +### 2. Workspace Dashboard View + +**Persistence**: none, derived from `WorkspaceOverviewBuilder` +**Owner**: `WorkspaceOverview` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace` | object | yes | Workspace summary for the active workspace | +| `overview_payload` | array | yes | Existing builder output for signal cards, summaries, and quick actions | +| `environment_chooser_url` | string | yes | Canonical workspace-scoped environment chooser route | +| `operations_url` | string | yes | Canonical workspace operations route | + +**Rules**: + +- The workspace dashboard is the primary decision surface after workspace selection. +- It must not silently behave like a second environment dashboard. +- It reuses current builder output rather than introducing a new summary system. + +### 3. Environment Chooser View + +**Persistence**: none, derived from `ManagedTenantsLanding` data plus existing `ChooseTenant` selection logic +**Owner**: workspace-scoped environment chooser surface + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace` | object | yes | Active workspace summary | +| `environments` | list | yes | Selectable managed environments within the workspace | +| `open_environment_url` | string | yes | Workspace-first environment dashboard URL per row/card | +| `switch_workspace_url` | string | no | Secondary escape hatch back to workspace choice | + +**Environment item fields**: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | int | yes | ManagedEnvironment key | +| `slug` | string | yes | Route-safe environment identifier | +| `name` | string | yes | Operator-visible environment label | +| `lifecycle_status` | string | yes | Existing operability/lifecycle status | +| `posture_hint` | string | no | Existing discoverability/operability summary only | + +**Rules**: + +- Only environments belonging to the active workspace and accessible to the actor appear. +- Archived or otherwise non-selectable environments do not appear and do not resolve. +- `ChooseTenant` may survive as an implementation seam, but not as a second public chooser contract. + +### 4. Managed Environment Page Context + +**Persistence**: derived from workspace-first route parameters plus `WorkspaceContext` +**Owner**: all environment-scoped pages touched by this slice + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace_id` | int | yes | Outer scope boundary | +| `workspace_slug` | string | yes | Outer scope route key | +| `managed_environment_id` | int | yes | Nested environment scope | +| `managed_environment_slug` | string | yes | Nested environment route key | +| `managed_environment_name` | string | yes | Operator-visible environment label | +| `breadcrumb_segments` | list | yes | `Workspace -> Managed Environment -> page` | +| `page_category` | string | yes | Derived route classification after legacy families are removed | + +**Rules**: + +- The `{environment}` parameter must belong to the `{workspace}` parameter or the request is `404`. +- Breadcrumb and context-bar ordering must always be `Workspace -> Managed Environment -> page`. +- No touched page may keep `/admin/t` or `/admin/tenants/{environment}` as a valid public route. + +### 5. Managed Environment Dashboard View + +**Persistence**: none, derived from `TenantDashboardSummaryBuilder` and current dashboard widgets +**Owner**: `TenantDashboard` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace` | object | yes | Outer workspace summary for breadcrumb/context | +| `managed_environment` | object | yes | Active environment summary | +| `dashboard_summary` | array | yes | Existing summary-builder payload | +| `primary_follow_up_url` | string | no | Existing recommended-action destination | +| `operations_url` | string | yes | Canonical workspace operations route with explicit environment filter | + +**Rules**: + +- The surface remains the canonical environment dashboard. +- Existing widget and header-action ownership stays intact. +- Operations navigation from this surface always enters the workspace operations family with explicit environment context. + +### 6. Workspace Operations Scope + +**Persistence**: derived route/query/session state already modeled by `Monitoring\Operations` +**Owner**: canonical workspace operations hub and detail viewer + +| Field | Type | Required | Notes | +|---|---|---|---| +| `workspace_id` | int | yes | Required workspace scope | +| `managed_environment_id` | int | no | Optional environment prefilter | +| `tenant_scope` | string | no | Existing workspace-wide versus narrowed state flag | +| `active_tab` | string | no | Existing operations tab state | +| `problem_class` | string | no | Existing scoped deep-link filter | +| `nav_context` | object | no | Back-link label and URL for environment return flow | + +**Rules**: + +- The collection route is `/admin/workspaces/{workspace}/operations`. +- The detail route is `/admin/workspaces/{workspace}/operations/{run}`. +- Explicit environment filters outside the current workspace or actor entitlement are `404`. +- Stale remembered environment filters may be discarded, but explicit hostile filters may not widen scope silently. + +### 7. Searchable Destination Contract + +**Persistence**: none, derived from Filament resource configuration +**Owner**: touched globally searchable resources + +| Field | Type | Required | Notes | +|---|---|---|---| +| `resource_key` | string | yes | `workspace` or `managed_environment` | +| `record_title_attribute` | string | yes | Existing Filament record title attribute | +| `destination_kind` | string | yes | `view` or `edit` | +| `destination_route` | string | yes | Valid route after the workspace-first cutover | +| `global_search_enabled` | bool | yes | Search remains enabled only if destination stays valid | + +**Rules**: + +- `WorkspaceResource` remains searchable only if its view/edit destination stays valid. +- `TenantResource` remains searchable only if its view/edit destination stays valid after the environment route move. +- Touched surfaces that cannot satisfy Filament’s view/edit rule must be disabled from global search in the same slice. + +## Route Invariants + +- Public operator route families after the cutover are rooted at `/admin/workspaces/{workspace}`. +- `/admin/t/{environment}` is removed, not redirected. +- `/admin/tenants/{environment}/required-permissions` is removed, not redirected. +- `/admin/w/{workspace}/managed-tenants` is removed, not redirected. +- `/admin/operations` and `/admin/operations/{run}` are removed, not redirected. +- Shared builders and helpers must stop emitting `panel: 'tenant'` for touched operator destinations. +- `Workspace` is the only Filament tenant for operator routing; `ManagedEnvironment` is nested route context only. + +## State Transitions + +1. `NoWorkspaceSelected` -> `ChooseWorkspace` +2. `WorkspaceSelected` -> `WorkspaceDashboard` +3. `WorkspaceDashboard` -> `EnvironmentChooser` +4. `EnvironmentChooser` -> `ManagedEnvironmentDashboard` +5. `ManagedEnvironmentDashboard` -> `EnvironmentScopedPage` or `WorkspaceOperationsScope` +6. `WorkspaceSwitch` -> clear cross-workspace environment context before rendering the new workspace dashboard or chooser +7. `LegacyEnvironmentRouteRequested` -> `NotFound` +8. `LegacyWorkspaceChooserRequested` -> `NotFound` +9. `LegacyOperationsRouteRequested` -> `NotFound` + +## Deferred Boundaries + +- No new provider connection/profile entity or abstraction is introduced here. +- No artifact ownership retargeting is introduced here. +- No RBAC role or capability family change is introduced here. +- No UI copy neutralization or localization rewrite is introduced here. +- No quality-gate pack or no-legacy automation beyond the local grep/guard proof is introduced here. \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/plan.md b/specs/280-workspace-tenancy-environment-routing/plan.md new file mode 100644 index 00000000..2d2a94b6 --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/plan.md @@ -0,0 +1,294 @@ +# Implementation Plan: Filament Workspace Tenancy & Environment Routing Cutover + +**Branch**: `280-workspace-tenancy-environment-routing` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/280-workspace-tenancy-environment-routing/spec.md` + +## Summary + +Prepare the second reserved workspace-first cutover slice that removes the temporary `/admin/t` operator shell, makes `Workspace` the only Filament tenant, and moves environment work under the canonical workspace-first route family rooted at `/admin/workspaces/{workspace}/environments/{environment}`. The narrow implementation path reuses the existing `AdminPanelProvider`, `WorkspaceOverview`, `ManagedTenantsLanding` and `ChooseTenant` chooser flow, `TenantDashboard`, `Monitoring\Operations`, `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `OperationRunLinks`, and related navigation/context helpers while explicitly deferring Specs `281` through `287`. + +This plan stays intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no new persistence or provider extraction is introduced, no compatibility route or second panel survives, no artifact retargeting or RBAC redesign is absorbed, and no broad quality-gate or copy-neutralization work moves into this slice. + +## Inherited Baseline / Explicit Delta + +### Inherited baseline + +- Spec `279` already replaced the core managed-target noun with `ManagedEnvironment` and kept the `/admin/t/{environment}` shell as a temporary bridge. +- `apps/platform/app/Providers/Filament/AdminPanelProvider.php` currently owns the default `admin` panel, `/admin`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceOverview`, `TenantRequiredPermissions`, and the canonical workspace-scoped operations surface. +- `apps/platform/app/Providers/Filament/TenantPanelProvider.php` still owns `id('tenant')`, `path('admin/t')`, and `tenant(ManagedEnvironment::class, slugAttribute: 'slug')`, so the operator runtime still has a second panel and second route language. +- `apps/platform/bootstrap/providers.php` still registers both `AdminPanelProvider` and `TenantPanelProvider`. +- `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` still branches to `ChooseTenant` or `TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)`. +- `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Tenants/TenantPageCategory.php`, and `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` still encode `/admin/t`, `/admin/tenants/{environment}`, or explicit tenant-panel assumptions. +- `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Filament/Pages/Monitoring/Operations.php` still emit or expect tenant-panel destinations and workspace-canonical operations routes under `/admin/operations`. +- Route ownership in `apps/platform/routes/web.php` still exposes `/admin`, `/admin/operations`, `/admin/operations/{run}`, `/admin/w/{workspace}/managed-tenants`, `/admin/tenants/{tenant}/required-permissions`, and the Filament-generated `/admin/t/{tenant}/...` family at the same time. + +### Explicit delta in this plan + +- Collapse the operator-facing `tenant` panel into the existing `admin` panel and make `Workspace` the only Filament tenant. +- Replace the public `/admin/t/{environment}` and `/admin/tenants/{environment}/required-permissions` route families with one canonical workspace-first environment family. +- Remove the legacy chooser route `/admin/w/{workspace}/managed-tenants` and the legacy operations routes `/admin/operations` plus `/admin/operations/{run}` with no redirects, aliases, or hidden fallback readers. +- Re-home the existing workspace dashboard, environment chooser, environment dashboard, and operations hub under `/admin/workspaces/{workspace}/...` without creating replacement dashboards or new panel abstractions. +- Update middleware, route categorization, current-context resolution, breadcrumbs, context-bar signals, and page/deep-link builders as one route-contract change rather than page-by-page exceptions. +- Keep all provider extraction, governance-artifact retargeting, provider-neutral capability work, RBAC redesign, copy-neutralization, and broad no-legacy/quality-gate follow-through deferred to Specs `281` through `287`. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing workspace and environment authorization/context helpers, existing Filament panel providers and page/resource action-surface contracts +**Storage**: PostgreSQL, no new persistence or schema change in this slice +**Testing**: Pest feature tests, one Pest browser smoke, and focused grep/guard checks for legacy route language +**Validation Lanes**: fast-feedback, confidence, browser +**Target Platform**: Laravel monolith in `apps/platform` +**Project Type**: web application +**Performance Goals**: preserve current dashboard and operations responsiveness while changing only route ownership, panel tenancy, and context resolution; no new queue, polling, or asset load path +**Constraints**: no compatibility redirects or aliases, no second Filament tenant at the environment level, no new persistence, no provider extraction, no artifact retargeting, no RBAC redesign, no broad copy neutralization, no quality-gate pack work, provider registration stays in `apps/platform/bootstrap/providers.php`, and Filament remains v5 on Livewire v4 +**Scale/Scope**: one operator-panel tenancy flip, one canonical workspace-first environment route family, one workspace-first operations route family, and one bounded set of chooser/middleware/link/breadcrumb/current-context updates + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Providers/Filament/AdminPanelProvider.php` +- `apps/platform/app/Providers/Filament/TenantPanelProvider.php` +- `apps/platform/bootstrap/providers.php` +- `apps/platform/app/Filament/Pages/ChooseWorkspace.php` +- `apps/platform/app/Filament/Pages/ChooseTenant.php` +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` +- `apps/platform/app/Filament/Pages/WorkspaceOverview.php` +- `apps/platform/app/Filament/Pages/TenantDashboard.php` +- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` +- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` +- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` +- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` +- `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php` +- `apps/platform/app/Support/Tenants/TenantPageCategory.php` +- `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` +- `apps/platform/app/Support/OperationRunLinks.php` +- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` +- `apps/platform/app/Filament/Resources/TenantResource.php` +- `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` +- `apps/platform/routes/web.php` +- feature, browser, and guard coverage under `apps/platform/tests/Feature`, `apps/platform/tests/Browser`, and existing grep/guard patterns + +## Filament v5 / Panel Notes + +- **Livewire v4.0+ compliance**: this slice keeps Filament v5 on Livewire v4 and changes only panel tenancy, route ownership, and current-context handling. +- **Provider registration location**: any provider registration change stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`. +- **Global search rule**: touched searchable resources must keep a valid view or edit destination or be disabled from global search in the same implementation slice. Current repo truth shows `WorkspaceResource` and `TenantResource` as the search-eligible surfaces that need explicit review. +- **Destructive actions**: no new destructive actions are introduced. Any touched existing destructive actions must preserve `->requiresConfirmation()` plus current server-side authorization. +- **Asset strategy**: no new asset registration or deployment step is planned. Existing admin theme/asset handling remains unchanged. + +## Workspace-first Routing Fit + +- Reuse the existing `admin` panel instead of creating a third panel or a nested-environment Filament tenancy model. +- Treat `WorkspaceOverview` as the canonical workspace dashboard under `/admin/workspaces/{workspace}` rather than leaving `/admin` as a second environment-like dashboard route. +- Treat the current chooser stack as two reuse candidates: `ChooseWorkspace` remains the workspace chooser, while `ManagedTenantsLanding` plus `ChooseTenant` form the environment-selection seams to retarget to `/admin/workspaces/{workspace}/environments`. +- Treat `TenantDashboard` as the surviving managed-environment dashboard surface, but move its route ownership and breadcrumb/context shell under the workspace-first family. +- Treat `Monitoring\Operations` as the only operations hub, but move its public collection and detail routes under the workspace-first family and preserve explicit `managed_environment_id` filter context when entered from environment pages. +- Update `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `TenantPageCategory`, and `ResolvesPanelTenantContext` together so the same route family defines tenant-bound versus workspace-scoped behavior. +- Remove `/admin/t` and `/admin/tenants/{environment}/required-permissions` outright in line with LEAN-001 instead of adding redirects or dual readers. +- Remove `/admin/w/{workspace}/managed-tenants` and `/admin/operations` plus `/admin/operations/{run}` outright in line with LEAN-001 instead of leaving a second chooser or operations route language behind. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: workspace dashboard, environment chooser, managed-environment dashboard, operations hub, related navigation builders, breadcrumbs, context bars +- **State layers in scope**: shell, page, detail, URL-query +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first on workspace dashboard and environment chooser, diagnostics-second on environment dashboard and operations detail, raw/support evidence remains on downstream pages only +- **Raw/support gating plan**: unchanged capability-gated downstream evidence; this slice changes route truth and scope signals, not evidence disclosure +- **One-primary-action / duplicate-truth control**: workspace dashboard stays the workspace decision surface, environment chooser stays the one dominant selection step, and environment dashboard keeps one primary follow-up action while breadcrumbs/context bars carry scope once instead of duplicating summaries +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory until grep/guard proof shows `/admin/t` and `panel: 'tenant'` are gone from touched operator surfaces +- **Special surface test profiles**: standard-native-filament, global-context-shell, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke +- **Exception path and spread control**: none; this slice removes the prior shell exception rather than introducing a new one +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: Filament panel providers, chooser pages, workspace dashboard, environment dashboard, operations page, context-bar/navigation builders, middleware/current-context helpers, route classifiers, global-search destinations +- **Shared abstractions reused**: `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `WorkspaceContext`, `WorkspaceRedirectResolver`, `OperationRunLinks`, `RelatedNavigationResolver`, existing Filament page/resource action-surface declarations +- **New abstraction introduced? why?**: none planned; this slice should replace temporary routing/panel ownership in place +- **Why the existing abstraction was sufficient or insufficient**: existing builders and dashboards already own the correct operator truth, but the surrounding panel and route shell still speak a temporary environment-panel language that must be removed +- **Bounded deviation / spread control**: none; implementation must converge on the shared builders and one route language rather than growing another local escape hatch + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, shared link semantics only +- **Central contract reused**: `OperationRunLinks`, `RelatedNavigationResolver`, `Monitoring\Operations`, `CanonicalNavigationContext` +- **Delegated UX behaviors**: workspace-safe URL resolution for the operations collection/detail routes, explicit environment prefiltering via `managed_environment_id`, and environment-to-operations back-link context +- **Surface-owned behavior kept local**: environment dashboards and environment-scoped pages supply only the filter context and back-navigation label for the existing operations surface +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: provider-specific copy or metadata left on untouched classes such as `TenantResource`, provider connection details, Microsoft-specific profile/permission semantics deferred to later specs +- **Platform-core seams**: panel tenancy, workspace-first route nouns, chooser wording, breadcrumb/context hierarchy, current-context resolution, workspace/environment-safe deep-link builders +- **Neutral platform terms / contracts preserved**: `workspace`, `managed environment`, `environment`, `operations`, `findings`, `backup sets`, `evidence`, `required permissions` +- **Retained provider-specific semantics and why**: untouched provider-owned class names or copy may still use `tenant` until Specs `281` and `286`; this slice only makes the public routing and context contract truthful now +- **Bounded extraction or follow-up path**: Specs `281`, `282`, `285`, `286`, and `287` + +## Constitution Check + +*GATE: Must pass before implementation begins and again after the design artifacts are complete.* + +- Inventory-first / snapshot truth: PASS. This slice changes routing, tenancy, and navigation only; inventory and snapshot semantics remain untouched. +- Read/write separation: PASS. No new write workflow or remote-call behavior is introduced. +- Graph contract path: PASS. No Graph call, endpoint, or contract-registry work is introduced. +- Deterministic capabilities: PASS. Current capability derivation remains; the slice only changes route and panel ownership around existing checks. +- RBAC-UX plane separation: PASS. `/admin` and `/system` remain separate. +- Workspace isolation: PASS. Workspace membership remains the first route boundary and must stay deny-as-not-found for non-members. +- Managed-environment isolation: PASS. Wrong-workspace or inaccessible environment resolution remains `404`, including explicit hostile filter hints. +- Destructive action discipline: PASS by non-expansion. Touched destructive actions keep existing `->requiresConfirmation()` and server-side authorization. +- Global search safety: PASS with implementation condition. `WorkspaceResource` and `TenantResource` must keep valid view/edit destinations or be disabled from global search. +- OperationRun / Ops-UX: PASS. The slice reuses the shared operations hub and changes only route/link semantics. +- Data minimization: PASS. No new data or provider-specific persistence is introduced. +- Test governance: PASS. Proof stays bounded to focused feature coverage, one browser smoke, and grep/guard checks. +- Proportionality / no premature abstraction: PASS. The slice removes a temporary second panel and route family instead of adding framework machinery. +- Persisted truth / behavioral state: PASS. No new persistence, state family, DTO layer, taxonomy, or abstraction is introduced. +- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native Filament surfaces and shared builders remain the first path. +- Provider boundary: PASS. The plan removes provider-shaped routing from platform-core navigation instead of deepening it. + +**Gate evaluation**: PASS. + +**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml`, and `checklists/requirements.md` remain aligned and continue to defer Specs `281` through `287` explicitly. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature, Browser +- **Affected validation lanes**: fast-feedback, confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: the cutover changes public routes, panel tenancy, chooser flow, breadcrumbs, navigation helpers, and deny-as-not-found semantics. Feature tests prove route ownership and authorization boundaries, while one browser smoke proves the surviving admin-panel flow from workspace selection to environment dashboard to workspace operations. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` +- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace membership, managed-environment membership, and remembered-context behaviors without widening helper defaults +- **Expensive defaults or shared helper growth introduced?**: no; reuse current workspace/environment helpers and keep any new fixture setup opt-in +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament relief for most pages; one browser smoke is still required because the entry flow crosses chooser, dashboard, and operations surfaces +- **Closing validation and reviewer handoff**: rerun the exact commands above, verify that `TenantPanelProvider` no longer owns public operator routing, confirm `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, and `/admin/operations` plus `/admin/operations/{run}` are gone with no redirect/alias fallback, confirm `ChooseTenant`, `ManagedTenantsLanding`, and `WorkspaceRedirectResolver` open workspace-first destinations, confirm `OperationRunLinks` and `RelatedNavigationResolver` emit workspace-first operations URLs with explicit environment context, confirm `TenantPageCategory`, `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, and `ResolvesPanelTenantContext` no longer treat the removed route families as active language, confirm `WorkspaceResource` and `TenantResource` still satisfy the global-search destination rule or are disabled, confirm destructive actions touched by the move still require confirmation, and confirm no new asset or deploy step appears +- **Budget / baseline / trend follow-up**: contained feature-local increase only +- **Review-stop questions**: did a compatibility route or dual-panel fallback appear, did legacy `panel: 'tenant'` links survive, did `/admin/t`, `/admin/tenants/{environment}`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` remain reachable, did wrong-workspace or hostile filter access stop being `404`, did the operations hub lose explicit environment context, did the slice absorb deferred Spec `281`-`287` work +- **Escalation path**: `reject-or-split` if the implementation introduces compatibility routes, provider extraction, RBAC redesign, or broad copy/quality-gate work +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the remaining adjacent work is already reserved as Specs `281` through `287`; routine routing/tenancy proof stays inside this feature + +## Review Checklist Status + +- **Review checklist artifact**: `checklists/requirements.md` +- **Review outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Escalation rule**: if implementation keeps `/admin/t`, keeps `panel: 'tenant'` links, adds compatibility redirects, or absorbs deferred provider/artifact/RBAC/copy/quality-gate work, flip the workflow outcome to `split` or `reject-or-split` + +## Rollout Considerations + +- Land the panel-tenancy flip, route-family rewrite, and current-context helper update as one bounded implementation slice so workspace/environment truth changes atomically. +- Remove the temporary `tenant` panel from operator routing instead of leaving a dormant compatibility shell behind. +- Retarget chooser flow and shared link builders before polishing individual environment pages so route truth changes at the shared seams first. +- Keep direct route guards and grep checks in the same change set so `/admin/t`, `/admin/tenants/{environment}`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, and `panel: 'tenant'` do not re-enter the codebase quietly. + +## Risk Controls + +- Reject any implementation that leaves `/admin/t/{environment}`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}` reachable through redirects, aliases, or hidden fallback readers. +- Reject any implementation that introduces nested or dual Filament tenancy rather than making `Workspace` the only Filament tenant. +- Reject any implementation that rebuilds dashboard signal systems instead of reusing `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder`. +- Reject any implementation that retargets operations links page by page instead of through `OperationRunLinks` and related navigation helpers. +- Reject any implementation that widens scope into provider extraction, artifact retargeting, RBAC redesign, copy neutralization, or no-legacy quality-gate work reserved for Specs `281` through `287`. + +## Research & Design Outputs + +- `research.md` records the surviving panel choice, workspace-only Filament tenancy, chooser-flow reuse plan, shared operations-link strategy, middleware/current-context convergence plan, global-search treatment, and proof strategy. +- `data-model.md` captures the route-context and page-view-model contracts for workspace dashboard, environment chooser, managed-environment dashboard, workspace operations, and searchable destinations. +- `quickstart.md` gives reviewers the bounded cutover flow and exact validation commands. +- `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` captures the conceptual GET-route and removed-route contract for the workspace-first runtime. +- `checklists/requirements.md` records package readiness, boundedness, and outcome state. + +## Project Structure + +### Documentation (this feature) + +```text +specs/280-workspace-tenancy-environment-routing/ +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── workspace-tenancy-environment-routing.logical.openapi.yaml +├── data-model.md +├── plan.md +├── quickstart.md +├── research.md +├── spec.md +└── tasks.md +``` + +### Source Code (expected implementation surfaces) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Concerns/ +│ │ │ └── ResolvesPanelTenantContext.php +│ │ ├── Pages/ +│ │ │ ├── ChooseTenant.php +│ │ │ ├── ChooseWorkspace.php +│ │ │ ├── TenantDashboard.php +│ │ │ ├── TenantRequiredPermissions.php +│ │ │ ├── WorkspaceOverview.php +│ │ │ ├── Monitoring/ +│ │ │ │ └── Operations.php +│ │ │ └── Workspaces/ +│ │ │ └── ManagedTenantsLanding.php +│ │ └── Resources/ +│ │ ├── TenantResource.php +│ │ └── Workspaces/ +│ │ └── WorkspaceResource.php +│ ├── Providers/Filament/ +│ │ ├── AdminPanelProvider.php +│ │ └── TenantPanelProvider.php +│ ├── Http/Middleware/ +│ │ └── EnsureWorkspaceSelected.php +│ └── Support/ +│ ├── Middleware/ +│ │ └── EnsureFilamentTenantSelected.php +│ ├── Navigation/ +│ │ └── RelatedNavigationResolver.php +│ ├── Tenants/ +│ │ └── TenantPageCategory.php +│ ├── Workspaces/ +│ │ └── WorkspaceRedirectResolver.php +│ └── OperationRunLinks.php +├── bootstrap/ +│ └── providers.php +├── routes/ +│ └── web.php +└── tests/ + ├── Browser/ + └── Feature/ +``` + +**Structure decision**: keep the documentation package self-contained under `specs/280-workspace-tenancy-environment-routing/`; later implementation should modify the existing `apps/platform` panel, route, middleware, and navigation seams directly instead of introducing a parallel routing subsystem. + +## Complexity Tracking + +No constitution violation or bloat exception is introduced by the plan. The slice removes a temporary panel and route family and adds no new persistence, abstraction, state family, or taxonomy. + +## Proportionality Review + +- **Current operator problem**: the operator runtime still communicates two public truths by splitting workspace work under `/admin` and environment work under `/admin/t`. +- **Existing structure is insufficient because**: the temporary second panel is encoded in providers, chooser redirects, middleware, route categorization, breadcrumbs, and shared deep-link builders, so a local redirect fix would preserve the same structural drift. +- **Narrowest correct implementation**: reuse the existing `admin` panel, dashboard builders, chooser surfaces, and operations hub while replacing only the panel-tenancy and route-contract shell around them. +- **Ownership cost created**: focused route, middleware, link-builder, and browser/feature proof work across several shared seams. +- **Alternative intentionally rejected**: keeping `/admin/t` with redirects or dual-panel ownership, because it preserves the temporary shell and contradicts the pre-production no-legacy doctrine. +- **Release truth**: current-release truth; this is a bounded cutover slice, not future-framework preparation. diff --git a/specs/280-workspace-tenancy-environment-routing/quickstart.md b/specs/280-workspace-tenancy-environment-routing/quickstart.md new file mode 100644 index 00000000..c330a4d0 --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/quickstart.md @@ -0,0 +1,53 @@ +# Quickstart: Filament Workspace Tenancy & Environment Routing Cutover + +## Reviewer Flow + +1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together. +2. Confirm the package stays on reserved slot `280` only and treats Spec `279` as prerequisite context rather than reopened scope. +3. Confirm the verified current shell still exists today: `TenantPanelProvider` owns `path('admin/t')` and `tenant(ManagedEnvironment::class, slugAttribute: 'slug')`. +4. Confirm the cutover removes the public `/admin/t/{environment}`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, and `/admin/operations` plus `/admin/operations/{run}` families with no redirects, aliases, or dual-panel fallback. +5. Confirm the final route contract is workspace-first: `/admin` for chooser/dashboard entry only, `/admin/workspaces/{workspace}` for the workspace dashboard, `/admin/workspaces/{workspace}/environments` for environment selection, `/admin/workspaces/{workspace}/environments/{environment}` for the environment dashboard, and `/admin/workspaces/{workspace}/operations` for the operations hub. +6. Confirm the plan reuses existing shared ownership points instead of inventing replacements: `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `ManagedTenantsLanding`, `ChooseTenant`, `WorkspaceRedirectResolver`, `OperationRunLinks`, and `RelatedNavigationResolver`. +7. Confirm current-context and route-classification seams are updated together in the implementation plan: `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `TenantPageCategory`, and `ResolvesPanelTenantContext`. +8. Confirm the global-search rule stays explicit for `WorkspaceResource` and `TenantResource`. +9. Confirm Specs `281` through `287` remain explicitly deferred. +10. Confirm no application implementation is included in this prep package. + +## Planned Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php) + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php) + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent) + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap" + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap" + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap" + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap" + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap" + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php" +``` + +## Review Questions + +- Does the package clearly remove the temporary `/admin/t` shell instead of hiding it behind redirects or aliases? +- Does the package also remove `/admin/w/{workspace}/managed-tenants` and `/admin/operations` plus `/admin/operations/{run}` instead of leaving a second chooser or operations route language behind? +- Does the plan keep `Workspace` as the only Filament tenant and `ManagedEnvironment` as nested route context only? +- Does the plan make the workspace-scoped environment chooser the canonical public environment entry surface instead of preserving two chooser routes? +- Do `OperationRunLinks`, `RelatedNavigationResolver`, and `Monitoring\Operations` remain the sole shared ownership path for operations deep links and back-navigation context? +- Are `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `TenantPageCategory`, and `ResolvesPanelTenantContext` treated as one combined current-context seam rather than independent local tweaks? +- Do `WorkspaceResource` and `TenantResource` either keep valid view/edit search destinations or get disabled from global search in the same slice? +- Does the package keep Filament on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, and avoid new asset/deploy steps? +- Do Specs `281` through `287` remain explicitly deferred rather than silently absorbed? + +## Notes + +- This prep package changes only planning artifacts under `specs/280-workspace-tenancy-environment-routing/`. +- No application implementation, tests, or runtime validation were executed while preparing the package. \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/research.md b/specs/280-workspace-tenancy-environment-routing/research.md new file mode 100644 index 00000000..36ef0010 --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/research.md @@ -0,0 +1,78 @@ +# Research: Filament Workspace Tenancy & Environment Routing Cutover + +**Date**: 2026-05-07 +**Branch**: `280-workspace-tenancy-environment-routing` + +## Decision 1: Collapse public operator routing into the existing `admin` panel + +- **Decision**: `AdminPanelProvider` remains the only operator-facing Filament panel, and `Workspace` becomes the only Filament tenant for operator admin routing. `TenantPanelProvider` stops owning public operator routes. +- **Rationale**: verified repo seams show `AdminPanelProvider` already owns `/admin`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceOverview`, and the current workspace-scoped operations hub, while `TenantPanelProvider` exists only to keep the temporary `/admin/t` shell alive. Filament v5 panel configuration on Laravel 12 still expects provider registration in `apps/platform/bootstrap/providers.php`, so the narrowest truthful cutover is to remove the second operator panel rather than preserve it as a redirect shell. +- **Alternatives considered**: + - Keep `TenantPanelProvider` as a redirect-only shell: rejected because it preserves the same split public truth and adds compatibility behavior the spec forbids. + - Introduce nested environment Filament tenancy: rejected because `ManagedEnvironment` should remain nested route context, not a second panel tenancy. + +## Decision 2: Make the workspace-scoped environment chooser the canonical public environment entry surface + +- **Decision**: the canonical environment chooser route lives at `/admin/workspaces/{workspace}/environments`, reusing the current workspace-bound `ManagedTenantsLanding` surface. `ChooseTenant` remains an implementation seam to absorb or delegate selection logic, but it should not survive as a second canonical public chooser route, and `/admin/w/{workspace}/managed-tenants` is retired with no redirect or alias. +- **Rationale**: `ManagedTenantsLanding` already binds a `Workspace` route parameter and lists accessible `ManagedEnvironment` records for that workspace. `ChooseTenant` currently mixes workspace recovery, selection, and redirection into the temporary tenant panel. Keeping the workspace-scoped landing as the public chooser keeps the route language aligned with the new workspace-first contract. +- **Alternatives considered**: + - Keep both `ChooseTenant` and `ManagedTenantsLanding` public: rejected because it preserves two chooser URLs for the same operator decision. + - Continue auto-branching from workspace selection directly into one environment dashboard: rejected because the spec treats the workspace dashboard and environment chooser as explicit decision surfaces, not hidden branching behavior. + +## Decision 3: Keep `/admin` tied to workspace selection or the workspace dashboard + +- **Decision**: direct `/admin` requests resolve only to workspace selection or the canonical workspace dashboard. Environment entry becomes explicit through the workspace dashboard and environment chooser instead of using `/admin` as a hidden second environment dashboard route. +- **Rationale**: the spec requires `/admin` to remain the operator entrypoint only, not a second canonical environment route. This keeps the workspace dashboard as the primary decision surface and removes the need to infer operator scope from a remembered environment. +- **Alternatives considered**: + - Keep `/admin` auto-redirecting into one environment when possible: rejected because it weakens the workspace-first shell and makes route truth dependent on remembered tenant context. + - Leave `/admin` ambiguous between workspace and environment dashboards: rejected because it preserves the current split truth. + +## Decision 4: Reuse the existing workspace and environment dashboards under new route ownership + +- **Decision**: `WorkspaceOverview` plus `WorkspaceOverviewBuilder` remain the workspace dashboard, and `TenantDashboard` plus `TenantDashboardSummaryBuilder` remain the managed-environment dashboard. The slice changes route ownership, breadcrumbs, context-bar signals, and deep links only. +- **Rationale**: current repo truth already has the signal builders and widget families needed for this slice. The missing piece is not new dashboard content, but the temporary panel and route shell around it. +- **Alternatives considered**: + - Build new dashboard pages for workspace-first routing: rejected because it would duplicate existing signal ownership and absorb UI work the spec explicitly defers. + - Refactor builder ownership now: rejected because provider extraction and copy neutralization are deferred to later specs. + +## Decision 5: Move operations into a workspace-first route family through shared navigation builders + +- **Decision**: `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}` become the canonical operations routes, and `OperationRunLinks`, `RelatedNavigationResolver`, and `Monitoring\Operations` remain the only shared seams for retargeting collection/detail URLs, environment filters, and back-navigation context. The legacy `/admin/operations` family is retired with no redirect or alias. +- **Rationale**: `Monitoring\Operations` already models `managed_environment_id`, `tenant_scope`, `problemClass`, `activeTab`, and navigation context as workspace-scoped state. The current defect is the public route family and back-link language, not the monitoring page itself. +- **Alternatives considered**: + - Keep `/admin/operations` as the canonical route: rejected because the spec explicitly requires the workspace-first operations family. + - Add environment-local operations pages: rejected because the operations hub is already the canonical shared monitoring surface. + +## Decision 6: Update middleware, route categorization, and panel-context resolution together + +- **Decision**: `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `TenantPageCategory`, `ResolvesPanelTenantContext`, and `WorkspaceRedirectResolver` must move to the workspace-first environment family in the same implementation slice. +- **Rationale**: those seams currently special-case `/admin/t`, `/admin/tenants/{environment}`, workspace-canonical `/admin/operations`, and current panel ID. Partial updates would leave stale remembered environment context, wrong 404/403 handling, or mixed breadcrumb language. +- **Alternatives considered**: + - Route redirects only: rejected because redirects would preserve the old route language and leave classifier logic stale. + - Query-parameter-only environment context: rejected because the spec requires canonical workspace-first environment routes, not implicit query-driven context. + +## Decision 7: Keep global search truthful only where destinations remain valid + +- **Decision**: the touched search-eligible surfaces are `WorkspaceResource` and `TenantResource`. Each remains globally searchable only if it still has a valid view or edit destination under the workspace-first route contract; otherwise search is disabled in the same slice. +- **Rationale**: Filament v5 global search requires a resource to have a view or edit page in order to produce valid results. Current repo truth shows most other touched resources are already `isGloballySearchable = false`, so the search review surface is small and explicit. +- **Alternatives considered**: + - Leave current global search behavior unchanged and fix later: rejected because it can leave broken results or inaccurate hints immediately after the route cutover. + +## Decision 8: Prove the cutover with focused feature coverage, one browser smoke, and grep/guard checks + +- **Decision**: use focused feature tests for routing and authorization, one browser smoke for the workspace-to-environment-to-operations path, and explicit grep/guard checks for `/admin/t` and `panel: 'tenant'` regressions. +- **Rationale**: Filament documentation emphasizes testing authorization and Livewire page behavior end to end, and the riskiest regression here is the live entry flow across chooser, dashboard, and operations surfaces. One narrow browser smoke is enough; broader browser or governance suites would be disproportionate. +- **Alternatives considered**: + - Feature tests only: rejected because they would not prove the live chooser-to-dashboard flow on the surviving panel. + - Broad browser suite: rejected because it would create a new heavy cost center for a routing cutover slice. + +## Final Research Outcome + +- The final operator runtime should have one public panel and one public route language rooted in workspace scope. +- The workspace-scoped environment chooser should become the public environment entry surface, with `ChooseTenant` absorbed into the same flow rather than preserved as a second public chooser URL. +- The legacy chooser route `/admin/w/{workspace}/managed-tenants` and the legacy operations family `/admin/operations` must not survive as redirects, aliases, or hidden fallback readers. +- The workspace dashboard and managed-environment dashboard should be reused as-is under new route ownership. +- Operations routing should move through the shared builders and preserve explicit environment scope via `managed_environment_id` and back-link context. +- Middleware, page categorization, and panel-context resolution must change together so deny-as-not-found and remembered-context semantics stay correct. +- Global search must remain truthful for `WorkspaceResource` and `TenantResource` or be disabled in the same slice. +- The narrowest honest proof is feature coverage, one browser smoke, and grep/guard checks. \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/spec.md b/specs/280-workspace-tenancy-environment-routing/spec.md new file mode 100644 index 00000000..3abe659c --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/spec.md @@ -0,0 +1,394 @@ +# Feature Specification: Filament Workspace Tenancy & Environment Routing Cutover + +**Feature Branch**: `280-workspace-tenancy-environment-routing` +**Created**: 2026-05-07 +**Status**: Ready +**Input**: User description: "Work only in /Users/ahmeddarrazi/Documents/projects/wt-plattform on branch 280-workspace-tenancy-environment-routing. Update only spec-prep artifacts, no application code. Fill /Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/280-workspace-tenancy-environment-routing/spec.md as the implementation-ready spec for candidate `280 - Filament Workspace Tenancy & Environment Routing Cutover` from docs/product/spec-candidates.md and docs/product/roadmap.md." + +## Spec Candidate Check + +- **Problem**: TenantPilot's operator runtime is still split between the workspace-scoped admin panel under `/admin` and the temporary ManagedEnvironment-scoped Filament panel under `/admin/t`. That leaves the public route contract, breadcrumbs, chooser flow, middleware, and deep-link builders behind the core model cutover completed in Spec `279`. +- **Today's failure**: Verified repo seams such as `TenantPanelProvider`, `ChooseTenant`, `WorkspaceRedirectResolver`, `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `TenantPageCategory`, `OperationRunLinks`, and many `panel: 'tenant'` links still route operators into `/admin/t/{environment}` or `/admin/tenants/{environment}`. Operators move from a workspace surface into a second panel with a different route language, while current operations remain canonical at workspace scope. The product therefore still communicates two different truths about where environment work actually lives. +- **User-visible improvement**: Operators get one workspace-first admin runtime: workspace dashboard, workspace-scoped environment chooser, managed-environment dashboard, workspace-scoped operations hub, and breadcrumbs that consistently read `Workspace -> Managed Environment -> domain page`. +- **Smallest enterprise-capable version**: Collapse the temporary `/admin/t` panel into the existing `/admin` admin panel, configure `Workspace` as the only Filament tenant, move environment pages under `/admin/workspaces/{workspace}/environments/{environment}`, make `/admin/workspaces/{workspace}/operations` the canonical operations route, reuse the existing workspace and environment dashboard builders, and remove the old route families without adding aliases or compatibility redirects. +- **Explicit non-goals**: No provider extraction, no governance-artifact retargeting, no provider-neutral capability registry, no provider-neutral artifact taxonomy, no RBAC redesign, no UI copy neutralization beyond route/breadcrumb/context truth needed by this slice, no quality-gate pack beyond feature-local routing/tenancy proof, no customer-portal cutover, no second Filament tenancy at the environment level, and no compatibility routes or dual-panel end state. +- **Permanent complexity imported**: One admin-panel tenancy flip from temporary environment-panel ownership to workspace tenancy, one canonical workspace-first environment route family, one route/breadcrumb/current-context contract update across chooser and deep-link seams, and focused feature/browser plus guard coverage. No new persistence, enum family, abstraction layer, or asset system is introduced. +- **Why now**: Spec `279` already changed the core managed-target noun to `ManagedEnvironment`, and the verified controlling seam still shows the old shell in `TenantPanelProvider` with `path('admin/t')` and `tenant(ManagedEnvironment::class, slugAttribute: 'slug')`. Until `280` closes that shell, every later reserved cutover slice (`281`-`287`) sits on an intentionally temporary panel and route contract. +- **Why not local**: This is not one page or one redirect. The temporary shell is encoded in provider registration, chooser flow, middleware, route categorization, current-context resolution, dashboard links, operations links, and resource URLs. A local fix would create the same hidden compatibility layer that the candidate and constitution reject. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Many touched operator surfaces and a foundation-sounding cutover. Defense: the slice explicitly reuses existing workspace and environment dashboard builders, removes a temporary panel instead of adding framework machinery, forbids compatibility routes, and defers the remaining provider/artifact/RBAC/copy/quality follow-up to reserved Specs `281`-`287`. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields + +- **Scope**: workspace +- **Primary Routes**: + - `/admin` remains the operator entrypoint only and resolves to workspace chooser or the currently selected workspace dashboard; it is not a second canonical environment dashboard route + - `/admin/workspaces/{workspace}` becomes the canonical workspace dashboard route for the active workspace + - `/admin/workspaces/{workspace}/environments` becomes the canonical workspace-scoped environment chooser route + - `/admin/workspaces/{workspace}/environments/{environment}` becomes the canonical managed-environment dashboard route + - `/admin/workspaces/{workspace}/environments/{environment}/{domain...}` becomes the canonical family for environment-scoped resources, diagnostics, and detail pages that today resolve through `panel: 'tenant'` or `/admin/tenants/{environment}` + - `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}` become the canonical operations collection/detail routes + - `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, and `/admin/operations/{run}` are removed rather than retained as redirects, aliases, or hidden fallback readers + - `/admin/t/{environment}` and `/admin/tenants/{environment}/required-permissions` are removed rather than retained as aliases +- **Data Ownership**: + - `Workspace` remains the workspace-owned root context and becomes the only Filament tenant for operator admin routing + - `ManagedEnvironment` remains the managed target nested inside a workspace and is resolved from workspace-first route parameters and current-context helpers, not from a second Filament tenant registration + - no table ownership, artifact ownership, provider-profile ownership, or RBAC ownership changes are introduced in this slice +- **RBAC**: + - workspace membership remains the first isolation boundary + - managed-environment access remains the second isolation boundary + - wrong-workspace or wrong-environment access remains `404` + - in-scope actors missing capability remain `403` + - no new role family, capability family, or authorization plane is introduced + +Canonical-view handling for workspace-scoped monitoring surfaces: + +- **Default filter behavior when tenant-context is active**: when an operator enters a workspace-scoped canonical view from a managed-environment dashboard or environment-scoped page, the canonical view opens prefiltered to that `managed_environment_id` inside the current workspace; widening to all environments remains explicit. +- **Explicit entitlement checks preventing cross-tenant leakage**: route-bound `{workspace}` and `{environment}` values must match each other and the current actor's entitlements. Explicit environment query hints outside the current workspace or outside the actor's membership resolve as `404`, while stale remembered filters are discarded instead of leaking or cross-loading another environment. + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, context selection, dashboard signals, breadcrumbs, page context bars, workspace-to-environment drillthroughs, operations deep links, and route builders +- **Systems touched**: `AdminPanelProvider`, `TenantPanelProvider`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceOverview`, `TenantDashboard`, `Monitoring\Operations`, `WorkspaceRedirectResolver`, `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `ResolvesPanelTenantContext`, `TenantPageCategory`, `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `OperationRunLinks`, and `RelatedNavigationResolver` +- **Existing pattern(s) to extend**: the existing `/admin` workspace panel, current workspace chooser flow, current `WorkspaceOverview` dashboard signals, current `TenantDashboard` environment signals, and the current tenant-safe operations link builders +- **Shared contract / presenter / builder / renderer to reuse**: `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `WorkspaceContext`, `WorkspaceRedirectResolver`, `OperationRunLinks`, `RelatedNavigationResolver`, and the existing Filament page/resource action-surface contracts +- **Why the existing shared path is sufficient or insufficient**: these shared seams already own the user-facing signals and links that the final routing contract needs. What is insufficient today is not the dashboard or monitoring content itself, but the temporary second-panel route ownership and the mixed `/admin/t` versus `/admin/tenants` public path language. +- **Allowed deviation and why**: none. This slice removes the temporary panel and route family instead of introducing another exception. +- **Consistency impact**: workspace chooser, environment chooser, dashboard links, operations links, breadcrumbs, context bar signals, and page-level deep links must all speak the same workspace-first route language and the same `Workspace -> Managed Environment -> domain page` hierarchy. +- **Review focus**: reviewers must verify that `WorkspaceOverview` and `TenantDashboard` are reused rather than replaced, that `OperationRunLinks` and related builders stop emitting `panel: 'tenant'` or `/admin/t` URLs, and that no second route language or compatibility redirect remains. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, link semantics only +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks`, `RelatedNavigationResolver`, the existing `Monitoring\Operations` page, and the existing canonical navigation context helpers +- **Delegated start/completion UX behaviors**: workspace-safe route resolution for operations index/detail, workspace-to-environment back-link context, and environment-prefiltered operations drillthrough via `managed_environment_id`; no new toast, queue, dedupe, terminal-notification, or artifact-link behavior is introduced +- **Local surface-owned behavior that remains**: environment dashboards and environment-scoped pages only supply the filter context and back-navigation label for the existing operations surface +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: panel tenancy, route nouns, chooser wording, page-category classification, breadcrumb context, current-context resolution, and deep-link builders that still encode temporary environment-panel ownership +- **Neutral platform terms preserved or introduced**: `workspace`, `managed environment`, `environment`, `operations`, `findings`, `backup sets`, `evidence`, and `required permissions` +- **Provider-specific semantics retained and why**: provider-owned Microsoft terms may remain in untouched provider copy, metadata, or class names such as `TenantResource` until Specs `281` and `286` handle provider extraction and copy neutralization. This slice only requires that routing, breadcrumbs, panel tenancy, chooser labels, and context signals touched by the cutover become workspace/environment truthful. +- **Why this does not deepen provider coupling accidentally**: the slice removes a provider-shaped public shell instead of entrenching it. `Workspace` becomes the Filament tenant, `ManagedEnvironment` remains nested context, and no provider-profile fields or Microsoft-specific routing semantics are added to the platform-core route contract. +- **Follow-up path**: Spec `281` for provider-profile extraction, Spec `282` for artifact retargeting, Spec `285` for workspace-first RBAC scope follow-through, Spec `286` for copy neutralization, and Spec `287` for explicit no-legacy enforcement + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Workspace dashboard under the canonical workspace route | yes | Native Filament page plus shared dashboard builders | navigation, dashboard signals, body CTA links | page, URL, summary state | no | Reuses `WorkspaceOverview` and `WorkspaceOverviewBuilder` rather than introducing a second workspace summary surface | +| Workspace-scoped environment chooser | yes | Native Filament simple page | context selection, navigation entry point | page, URL, remembered context | no | Reuses the existing chooser flow instead of adding a second environment selector | +| Managed-environment dashboard under the workspace-first route | yes | Native Filament dashboard plus shared widgets/builders | dashboard signals, header actions, page CTAs | page, URL, detail state | no | Reuses `TenantDashboard` and its current widget/action family; routing and breadcrumbs change, not the signal source | +| Workspace operations hub under `/admin/workspaces/{workspace}/operations` | yes | Native Filament monitoring page | operations links, back links, filter context | page, table, URL/query | no | Reuses the existing operations surface and moves only the public route ownership/context contract | +| Environment-scoped page family migrated off `/admin/t` and `/admin/tenants/{environment}` | yes | Native Filament resources/pages | navigation, breadcrumbs, current-context shell | page, detail, URL/query | no | Local action contracts stay intact; this slice changes route ownership and breadcrumb shell only | + +## Decision-First Surface Role + +| 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 | +|---|---|---|---|---|---|---|---| +| Workspace dashboard under the canonical workspace route | Primary Decision Surface | Operator scans one workspace to decide which environment or workflow needs attention next | workspace-wide signal counts, attention items, recent operations, and quick actions | deeper environment detail remains one click away on the environment dashboard or operations hub | Primary because it is the workspace landing surface after workspace selection | Follows workspace-first operating flow instead of dropping straight into one environment panel | Removes the current panel split and makes workspace scope explicit before environment work begins | +| Workspace-scoped environment chooser | Primary Decision Surface | Operator chooses the managed environment they intend to act in | environment identity, lifecycle posture, and current workspace context | deeper environment diagnostics stay on the environment dashboard | Primary because environment selection is the next explicit decision after workspace selection | Keeps selection inside the same workspace panel instead of jumping into a second panel | Removes the cognitive break between workspace selection and environment work | +| Managed-environment dashboard under the workspace-first route | Secondary Context Surface | Operator confirms the chosen environment and decides which domain page to open next | environment posture, chips, recommended action, and dashboard signals | raw/support detail stays where it already belongs on downstream pages | Secondary because it is an environment workbench, not the workspace-wide landing decision surface | Aligns with today's dashboard-driven environment workflow, but under truthful workspace-first routing | Reduces wrong-environment actions by keeping workspace and environment context visible together | +| Workspace operations hub under `/admin/workspaces/{workspace}/operations` | Primary Decision Surface | Operator reviews queued and completed operations for one workspace, optionally narrowed to one environment | current workspace, active environment filter if present, operation status/outcome, and canonical back link | deeper run detail stays on the run viewer | Primary because operations already serve as the canonical monitoring surface | Preserves the existing monitoring workflow while removing the need to infer environment context from a second panel | Reduces duplicate monitoring entry points and keeps widening/narrowing explicit | +| Environment-scoped page family migrated off `/admin/t` and `/admin/tenants/{environment}` | Secondary Context Surface | Operator performs the chosen domain task inside one managed environment | breadcrumb/context shell plus the existing page-local operational truth | existing diagnostics remain page-local and unchanged | Secondary because the decision is made on dashboard or operations surfaces; these pages carry the work forward | Preserves each page's current workflow contract while making its scope truthful in navigation | Removes path-language drift without creating new page-local decision systems | + +## Audience-Aware Disclosure + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Workspace dashboard under the canonical workspace route | operator-MSP, support-platform | workspace-wide attention, recent operations, and one next-action entry point | per-environment follow-up remains secondary | raw evidence stays on downstream pages | `Open environment` or `Open operations` depending on the top signal | raw evidence and low-level diagnostics remain downstream | workspace summary stays at workspace level and does not restate environment dashboards | +| Workspace-scoped environment chooser | operator-MSP, support-platform | current workspace plus selectable managed environments | lifecycle posture only | provider/raw detail hidden | `Open environment` | provider-specific raw metadata | chooser states selection truth once, then hands off to the environment dashboard | +| Managed-environment dashboard under the workspace-first route | operator-MSP, support-platform | environment summary, posture, and recommended next action | page-local diagnostics, support request detail, and additional widgets | raw evidence stays on dedicated downstream surfaces | primary follow-up action | support/raw evidence remains downstream and capability-gated where already required | dashboard shows the environment summary once and uses links for deeper evidence instead of repeating it | +| Workspace operations hub under `/admin/workspaces/{workspace}/operations` | operator-MSP, support-platform | workspace scope, optional environment filter, and operation follow-up state | run-level diagnostics remain on the run viewer | raw payloads remain on detail pages or related evidence surfaces | `Open operation` or `Back to environment` | low-level run evidence stays in detail | the hub states scope once and uses detail pages for deep evidence | +| Environment-scoped page family migrated off `/admin/t` and `/admin/tenants/{environment}` | operator-MSP, support-platform | breadcrumb scope plus each page's existing core truth | existing diagnostics stay on the page | support/raw evidence remains unchanged and page-local | the page's existing primary action | unrelated raw/support detail remains gated as today | the new shell adds context instead of duplicating page-local summaries | + +## UI/UX Surface Classification + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Workspace dashboard under the canonical workspace route | Dashboard / Context / Summary | Workspace landing dashboard | Open one environment or the operations hub | body CTA cards and quick actions | forbidden | body-level quick actions only | none | `/admin/workspaces/{workspace}` | `/admin/workspaces/{workspace}` | active workspace | Workspace | workspace-wide attention and recent operations | none | +| Workspace-scoped environment chooser | Selector / Context / Entry | Workspace environment selector | Open the intended managed environment | environment card/button | allowed | workspace switch remains secondary | none | `/admin/workspaces/{workspace}/environments` | `/admin/workspaces/{workspace}/environments/{environment}` | active workspace plus environment identity | Managed Environment | selectable environment identity and posture | none | +| Managed-environment dashboard under the workspace-first route | Dashboard / Detail / Workflow hub | Environment dashboard | Follow the recommended domain action | dashboard CTA from widget or header action | forbidden | grouped header secondary actions plus body links | none introduced by this slice | `/admin/workspaces/{workspace}/environments` | `/admin/workspaces/{workspace}/environments/{environment}` | active workspace and active managed environment | Managed Environment | environment posture, next action, and dashboard signals | none | +| Workspace operations hub under `/admin/workspaces/{workspace}/operations` | List / Table / Monitoring | Workspace monitoring hub | Inspect a run or widen/narrow environment scope | clickable row to run detail | required | header back-link and filter actions | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | active workspace and optional managed-environment filter | Operations / Operation | current scope, run state, and follow-up truth | none | +| Environment-scoped page family migrated off `/admin/t` and `/admin/tenants/{environment}` | List / Detail / Resource family | Workspace-first environment resources and diagnostics | Continue the chosen domain workflow | preserve each surface's current inspect model | preserve current contract | preserve current contract | preserve current contract | `/admin/workspaces/{workspace}/environments/{environment}/{domain}` | `/admin/workspaces/{workspace}/environments/{environment}/{domain}/{record?}` | breadcrumb `Workspace -> Managed Environment -> page` | Existing domain noun | active workspace/environment context plus each page's existing truth | none | + +## Operator Surface Contract + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace dashboard under the canonical workspace route | Workspace operator | Decide which environment or workspace-wide workflow needs attention first | Dashboard | What in this workspace needs my attention next? | workspace-wide attention, recent operations, quick actions | deeper environment evidence stays downstream | workspace attention, run activity, recovery posture | none | Open environment, Open operations | none | +| Workspace-scoped environment chooser | Workspace operator | Decide which managed environment to enter | Context-selection page | Which managed environment am I about to work in? | environment identity, posture, workspace context | deeper diagnostics remain on the dashboard | lifecycle posture, access posture | none | Open environment | none | +| Managed-environment dashboard under the workspace-first route | Environment operator | Decide which environment-scoped page to open next | Dashboard | What in this environment needs action now? | posture, dashboard chips, recommended action, summary widgets | support diagnostics and deeper evidence remain downstream | environment posture, readiness, attention state | existing page-local semantics only | Primary follow-up, More, Request support | none added by this slice | +| Workspace operations hub under `/admin/workspaces/{workspace}/operations` | Monitoring operator | Review runs across the workspace or one environment | Monitoring table/detail | Which operations need follow-up, and for which environment? | current scope, optional environment filter, run status/outcome, back link | run detail, evidence, and related links remain secondary | execution status, outcome, scope | read-only | Open operation, Back to environment, Show all environments | none | +| Environment-scoped page family migrated off `/admin/t` and `/admin/tenants/{environment}` | Environment operator | Perform the selected domain workflow in one environment | Resource/detail family | Am I acting in the correct workspace and managed environment? | breadcrumb scope and existing page truth | page-local diagnostics unchanged | preserve current domain-specific status families | preserve current page-local mutation semantics | preserve current page-local primary action | preserve current page-local dangerous actions | + +## Proportionality Review + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: the operator runtime still communicates two conflicting truths: workspace-wide work begins on `/admin`, but environment work still lives on a temporary `/admin/t` panel and a second `/admin/tenants/{environment}` route family. +- **Existing structure is insufficient because**: the current structure requires special-casing `/admin/t` and `/admin/tenants` in provider setup, middleware, route categorization, chooser flow, and deep-link builders. As long as that split remains, later provider/artifact/RBAC cutovers would be forced to build on a known temporary shell. +- **Narrowest correct implementation**: reuse the current admin panel, workspace dashboard, environment dashboard, chooser flow, and operations surface, and change only the tenancy and public route ownership needed to remove the temporary shell. +- **Ownership cost**: route and deep-link retargeting across chooser, middleware, builders, breadcrumbs, and tests; removal of one panel provider from the operator runtime; and focused browser/feature coverage to keep the route contract honest. +- **Alternative intentionally rejected**: leaving the `/admin/t` panel in place with redirects, keeping `/admin/tenants/{environment}` as a second route family, or adding nested/dual Filament tenancy. Those options preserve the temporary shell instead of closing it. +- **Release truth**: current-release truth; this slice removes temporary runtime structure rather than preparing future framework flexibility + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, redirect shims, dual-panel ownership, dual-context restore paths, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Feature, Browser +- **Validation lane(s)**: fast-feedback, confidence, browser +- **Why this classification and these lanes are sufficient**: this slice changes panel tenancy, public routes, chooser flow, breadcrumbs, deep-link builders, and workspace/environment authorization semantics. Feature tests prove route ownership, filter context, and deny-as-not-found behavior; one narrow browser smoke proves the chooser-to-dashboard-to-operations flow still works end to end on the surviving admin panel. +- **New or expanded test families**: one workspace-tenancy/routing feature family, one environment-routing/breadcrumb feature family, one operations canonical-context feature family, one legacy-route-removal or grep/guard family, and one narrow browser smoke for the workspace-to-environment flow +- **Fixture / helper cost impact**: moderate. Tests must seed a workspace plus managed environments and reuse existing workspace/member/environment context helpers. The slice should not introduce new default-heavy fixtures, providers, or session helpers beyond what current workspace and managed-environment tests already require. +- **Heavy-family visibility / justification**: one browser smoke only. No new heavy-governance or broad browser family is justified. +- **Special surface test profile**: standard-native-filament, global-context-shell, monitoring-state-page +- **Standard-native relief or required special coverage**: standard feature coverage is sufficient for provider registration, tenancy switch, route removal, and breadcrumb semantics. One browser smoke is required because the cutover changes the live operator entry flow across chooser, dashboard, and operations surfaces. +- **Reviewer handoff**: reviewers must verify that `AdminPanelProvider` becomes the only operator panel, any provider-registration change remains in `apps/platform/bootstrap/providers.php`, `TenantPanelProvider` and `/admin/t` cease to own public operator routing, `ChooseTenant` and `WorkspaceRedirectResolver` stop emitting `panel: 'tenant'` destinations, `OperationRunLinks` and related navigation open the workspace operations hub with explicit environment filters, `TenantPageCategory` and the two middleware seams no longer treat `/admin/t` or `/admin/tenants/{environment}` as active route languages, `WorkspaceOverview` and `TenantDashboard` are reused rather than replaced, `WorkspaceResource` and `TenantResource` keep valid view/edit search destinations or disable search if that contract cannot be kept, destructive actions touched by the route move still require confirmation and server authorization, Livewire remains v4, and no new asset registration/deploy step appears. +- **Budget / baseline / trend impact**: moderate feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- make `Workspace` the only Filament tenant for the operator admin runtime +- collapse the operator-facing `/admin/t` panel into the existing `/admin` admin panel +- remove the public `/admin/t/{environment}` route family with no compatibility alias +- remove the public `/admin/tenants/{environment}/required-permissions` family in favor of the workspace-first environment route family +- introduce the canonical workspace-first environment route family rooted at `/admin/workspaces/{workspace}/environments/{environment}` +- reuse `WorkspaceOverview` as the workspace dashboard and `TenantDashboard` as the managed-environment dashboard under the new route ownership +- make `/admin/workspaces/{workspace}/operations` the canonical operations route and thread environment pages into it through explicit filters and back-navigation context +- retarget chooser flow, middleware, route categorization, deep-link builders, breadcrumbs, and page-level context signals to the new route contract +- ensure touched globally searchable resources preserve a valid view/edit destination or are disabled from global search in the same slice + +### Non-Goals + +- provider connection, provider scope, or Microsoft profile extraction owned by Spec `281` +- governance artifact retargeting to `ManagedEnvironment` owned by Spec `282` +- provider capability registry or provider-neutral artifact taxonomy owned by Specs `283` and `284` +- workspace-first RBAC redesign or capability-family changes owned by Spec `285` +- UI copy, IA, and localization neutralization beyond the route/breadcrumb/context truth needed here, owned by Spec `286` +- cutover quality-gate pack work beyond the routing/tenancy slice proof needed here, owned by Spec `287` +- customer portal, website, package-engine, or guided-operations work +- compatibility routes, aliases, redirects that preserve `/admin/t`, dual panel ownership, dual-read/dual-write route logic, or hidden absorption of later reserved specs + +## Assumptions + +- Spec `279` already completed the core `ManagedEnvironment` cutover and remains prerequisite context only. +- `AdminPanelProvider` remains the long-lived operator panel under `/admin`, and removing `TenantPanelProvider` from operator routing is acceptable in this pre-production environment. +- The current workspace and environment dashboards already provide the operator signal content needed for the final route contract; `280` relocates and rekeys them instead of redesigning their signal models. +- Internal class names or untouched provider copy may still use `tenant` until later reserved slices, but routing, breadcrumbs, chooser labels, and operator-facing context signals touched by this slice must be workspace/environment truthful. + +## Risks + +- Hidden `/admin/t` or `panel: 'tenant'` references in builders, widgets, or tests could leave a partial second route language behind after the cutover. +- Operations deep links and back links could lose environment scope or workspace safety if they are retargeted page by page instead of through the shared link builders. +- Collapsing panel ownership could break current context bootstrap if middleware and route categorization are not updated together. +- The slice could sprawl into copy cleanup or RBAC redesign if reviewers do not hold the scope boundary against Specs `285` and `286`. + +## Candidate Selection Gate Summary + +- **Selected candidate**: `280 - Filament Workspace Tenancy & Environment Routing Cutover` +- **Source locations**: + - `docs/product/spec-candidates.md` under the reserved workspace-first / provider-neutral cutover pack + - `docs/product/roadmap.md` under the same reserved cutover ordering and sequencing note +- **Why selected now**: Spec `279` already changed the core managed-target noun, and the verified controlling seam still shows the temporary panel shell in `TenantPanelProvider`. `280` is the second reserved slot and closes that temporary public routing/tenancy contract before later provider and artifact follow-up work begins. +- **Why close alternatives were deferred**: + - `281` belongs to provider connection/profile extraction, not to route collapse + - `282` belongs to artifact retargeting, not to public route ownership + - `283` and `284` belong to provider-neutral packaging follow-through, not to the immediate shell removal + - `285`, `286`, and `287` are explicit follow-up guardrail slices for RBAC, copy, and no-legacy enforcement + - the already-existing mid-260/270 packages remain context only and are not the target of this reserved cutover slot +- **Smallest viable implementation slice**: one surviving workspace-tenanted admin panel, one canonical workspace-first environment route family, one canonical workspace operations route family, and one bounded set of chooser/middleware/link/breadcrumb updates with no aliases +- **Documented deviation from raw candidate wording**: `add a workspace dashboard` and `add a managed-environment dashboard` are satisfied by re-homing the existing `WorkspaceOverview` and `TenantDashboard` surfaces under their final route ownership rather than creating new summary systems. + +## Completed-Spec Guardrail Result + +- `specs/265-decision-register-approval/` already exists with `Status: Ready for implementation` and remains separate context only +- `specs/267-artifact-lifecycle-retention/` already exists with `Status: Ready for implementation` and remains separate context only +- `specs/274-billing-subscription-truth/` already exists with `Status: Ready for implementation` and remains separate context only +- `specs/275-customer-facing-localization-adoption/` already exists with `Status: Draft` and remains separate context only +- `specs/276-support-access-governance/` already exists with `Status: Ready for implementation` and remains a separate prepared package +- `specs/277-stored-reports-surface/` already exists with `Status: Ready for implementation` and remains a separate prepared package +- `specs/278-cross-domain-indicator-audit/` already exists with `Status: Implemented docs-only audit artifact; ready for manual review` and remains separate historical context only +- `specs/279-workspace-managed-environment-core/` already exists with `Status: Ready with approved feature-local exception`, carries implementation-close-out history in `tasks.md`, and is prerequisite context only for this package + +## Deferred Adjacent Candidates + +- `281 - Provider Connection, Provider Scope & Microsoft Profile Extraction` +- `282 - Governance Artifact Retargeting to ManagedEnvironment` +- `283 - Provider Capability Registry v1` +- `284 - Provider-neutral Artifact Source Taxonomy v1` +- `285 - Workspace-first RBAC & Environment Access Scoping` +- `286 - UI Copy, IA & Localization Neutralization` +- `287 - Cutover Quality Gates & No-Legacy Enforcement` + +## User Scenarios & Testing + +### User Story 1 - Enter an environment without leaving the workspace admin panel (Priority: P1) + +As an operator, I want to choose a workspace and then open one managed environment inside the same admin panel so I never have to cross into a second panel or learn a second route language just to work on that environment. + +**Why this priority**: this is the core cutover outcome. If the chooser still lands on `/admin/t`, the temporary shell remains active and the candidate is not complete. + +**Independent Test**: select a workspace, open the environment chooser, choose one managed environment, and confirm the destination is the canonical workspace-first environment dashboard rather than `/admin/t`. + +**Acceptance Scenarios**: + +1. **Given** an operator has selected a workspace, **When** they choose a managed environment, **Then** they land on `/admin/workspaces/{workspace}/environments/{environment}` inside the surviving admin panel. +2. **Given** an operator enters a workspace/environment URL where the environment belongs to a different workspace or is not accessible, **When** the route resolves, **Then** the app returns `404` and reveals no environment data. + +--- + +### User Story 2 - Move from environment work into workspace operations with explicit scope (Priority: P1) + +As an operator, I want environment dashboards and environment pages to open the workspace operations hub with an explicit environment filter so operations stay workspace-canonical without losing the narrower environment context I came from. + +**Why this priority**: the candidate explicitly requires `/admin/workspaces/{workspace}/operations` to be canonical and environment pages to link into it via filters. + +**Independent Test**: open one environment dashboard or environment page, follow its operations link, and confirm the workspace operations hub opens with the correct environment filter and return context. + +**Acceptance Scenarios**: + +1. **Given** an operator is on one managed-environment dashboard, **When** they open operations, **Then** the destination is `/admin/workspaces/{workspace}/operations` with that environment's filter active. +2. **Given** the operations hub is filtered to one environment, **When** the operator chooses to widen scope, **Then** the route stays workspace-canonical and the environment filter clears explicitly rather than switching panels. + +--- + +### User Story 3 - Read workspace-wide and environment-scoped signals on the correct surfaces (Priority: P2) + +As an operator, I want the workspace dashboard to summarize workspace-wide signals and the managed-environment dashboard to summarize one environment's signals so I can decide at the right scope without reconstructing context from mixed pages. + +**Why this priority**: the candidate requires both dashboards, and reusing the current builders is the narrowest honest way to deliver them. + +**Independent Test**: open a workspace dashboard and an environment dashboard for the same workspace, then verify the workspace view aggregates workspace-wide signals while the environment view remains scoped to one environment and shows the full breadcrumb hierarchy. + +**Acceptance Scenarios**: + +1. **Given** a workspace has multiple accessible environments, **When** the operator opens the workspace dashboard, **Then** it shows workspace-wide signals and recent operations rather than one environment's dashboard state. +2. **Given** the operator opens one managed-environment dashboard, **When** the page renders, **Then** the breadcrumb and context shell show `Workspace -> Managed Environment -> page` and the signals remain scoped to that one environment. + +--- + +### User Story 4 - Keep search and authorization truthful after the route cutover (Priority: P3) + +As an operator, I want globally searchable resources and direct URLs to open truthful destinations or disappear from search altogether so the route migration does not leave broken search results or unsafe hints behind. + +**Why this priority**: `WorkspaceResource` and `TenantResource` are already search-eligible by repo truth, and route changes can quietly break that contract if the spec does not pin it down. + +**Independent Test**: open touched global-search results and direct URLs for workspace and managed-environment surfaces, then confirm that authorized results open valid destinations and unauthorized paths remain not found. + +**Acceptance Scenarios**: + +1. **Given** a touched searchable resource remains globally searchable, **When** an operator opens a search result, **Then** the destination is a valid page under the canonical workspace-first route family. +2. **Given** a touched searchable surface cannot preserve a valid view or edit destination after the route move, **When** the cutover ships, **Then** that surface no longer appears in global search. + +### Edge Cases + +- A direct request to `/admin/t/{environment}`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, or `/admin/operations/{run}` must fail as not found rather than redirecting, aliasing, or silently restoring the old path family. +- If a remembered environment belongs to a different workspace than the newly selected workspace, the environment context must clear before the operator is shown any environment-bound page. +- Archived or otherwise non-selectable managed environments must not appear in the workspace-scoped chooser or resolve through the new route family. +- Explicit environment filters on workspace-canonical pages must reject inaccessible environments as `404`; stale remembered filters may be discarded, but explicit hostile hints may not quietly widen scope. +- Any touched globally searchable resource without an intact view/edit destination under the new route contract must be disabled from global search in the same implementation slice. + +## Requirements + +**Constitution alignment (required):** This slice changes routing, Filament tenancy, chooser flow, navigation, and deep-link behavior. It does not introduce Microsoft Graph calls, a new `OperationRun` lifecycle, or new persistence. + +**Constitution alignment (XCUT-001 / PROV-001 / UI-FIL-001):** The slice must reuse the existing shared workspace dashboard, managed-environment dashboard, chooser flow, operations hub, and deep-link builders. No second panel, second route language, or local dashboard framework may appear. + +**Constitution alignment (RBAC-UX):** Workspace membership stays the first boundary and managed-environment access stays the second boundary. Non-members remain `404`, in-scope capability denials remain `403`, and any touched destructive actions continue to require confirmation and server-side authorization. + +**Constitution alignment (TEST-GOV-001):** Proof stays bounded to feature coverage, one narrow browser smoke, and explicit route/URL guard checks. No heavy-governance family is introduced. + +### Functional Requirements + +- **FR-001**: The operator admin runtime MUST configure `Workspace` as the only Filament tenant after this cutover. +- **FR-002**: The system MUST stop using `ManagedEnvironment` as a Filament tenant and MUST remove the public `/admin/t/{environment}` route family rather than retaining it as a redirect, alias, or hidden fallback. +- **FR-003**: The surviving `/admin` admin panel MUST own the canonical workspace-first environment route family rooted at `/admin/workspaces/{workspace}/environments/{environment}`. +- **FR-004**: The admin entrypoint `/admin` MUST resolve to workspace selection or the current workspace dashboard only; it MUST NOT remain a second canonical environment dashboard route. +- **FR-005**: The system MUST reuse the existing workspace overview surface as the canonical workspace dashboard and the existing managed-environment dashboard surface as the canonical environment dashboard, rather than creating replacement dashboard systems. +- **FR-006**: The workspace-scoped chooser flow MUST open the new canonical environment dashboard route and MUST clear stale cross-workspace environment context before resolving any environment-bound page. +- **FR-007**: The workspace dashboard, environment dashboard, and all environment-scoped pages touched by this slice MUST present navigation and breadcrumbs in the order `Workspace -> Managed Environment -> domain page`. +- **FR-008**: `/admin/workspaces/{workspace}/operations` MUST be the canonical public operations collection route for the active workspace. +- **FR-009**: Environment dashboards and environment-scoped pages MUST open the workspace operations hub through explicit filter/query context, not through environment-panel ownership. +- **FR-010**: The operations detail route MUST remain inside the workspace route family so run detail does not depend on the removed `/admin/t` shell. +- **FR-011**: Route builders, deep links, and related-navigation helpers that currently emit `panel: 'tenant'`, `/admin/t`, or `/admin/tenants/{environment}` URLs MUST emit only the new canonical workspace-first routes. +- **FR-012**: `TenantPageCategory`, `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, `ResolvesPanelTenantContext`, and related current-context seams MUST treat the workspace-first environment family as the only active environment-bound route language. +- **FR-013**: The slice MUST preserve the current `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership of workspace-wide and environment-scoped summary signals. +- **FR-014**: Any touched globally searchable resource MUST preserve a valid view or edit destination under the new route contract or be disabled from global search in the same slice. +- **FR-015**: `WorkspaceResource` and `TenantResource` MUST remain globally searchable only if their view/edit destinations stay valid under the cutover route ownership. +- **FR-016**: The slice MUST NOT introduce compatibility routes, aliases, dual-panel ownership, dual route readers, hidden `/admin/t` absorption, or spillover into reserved Specs `281`-`287`. +- **FR-017**: The legacy chooser route `/admin/w/{workspace}/managed-tenants` MUST be removed rather than preserved as a redirect, alias, or hidden fallback reader. +- **FR-018**: The legacy operations routes `/admin/operations` and `/admin/operations/{run}` MUST be removed in favor of `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}` with no redirects, aliases, or dual-route ownership. + +### Authorization and Safety Requirements + +- **AR-001**: Workspace membership MUST remain the first access boundary for `/admin/workspaces/{workspace}/...` routes. +- **AR-002**: Managed-environment access MUST remain the second access boundary for `/admin/workspaces/{workspace}/environments/{environment}/...` routes. +- **AR-003**: A mismatched `{workspace}` and `{environment}` pair MUST resolve as `404` even if the actor has access to one of them individually. +- **AR-004**: Workspace-canonical pages that accept explicit environment filters MUST reject inaccessible environment hints as `404` and must not silently widen scope. +- **AR-005**: Any destructive action touched while retargeting dashboard or page links MUST remain server-authorized and require confirmation. + +### Non-Functional Requirements + +- **NFR-001**: Filament remains v5 on Livewire v4. +- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; if `TenantPanelProvider` is removed from operator routing, no registration is moved to `bootstrap/app.php`. +- **NFR-003**: Existing admin theme and asset registration remain unchanged; the slice introduces no new asset pipeline or deployment step beyond the current Filament asset practice. +- **NFR-004**: The cutover must preserve Action Surface Contract, UI-FIL-001, and UX-001 expectations for touched Filament pages/resources without introducing ad hoc Blade/Tailwind design systems or redundant inspect actions. +- **NFR-005**: The route cutover must stay reviewable as one bounded slice and must not silently absorb provider extraction, artifact retargeting, RBAC redesign, copy neutralization, or quality-gate work reserved for later specs. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace dashboard | `WorkspaceOverview` under `/admin/workspaces/{workspace}` | none | body CTA cards and quick actions only | none | none | existing workspace zero-state CTA family only | none | `N/A` | no new audit behavior | Reuses `WorkspaceOverviewBuilder`; action-surface exemptions remain intact | +| Workspace-scoped environment chooser | chooser surface under `/admin/workspaces/{workspace}/environments` | none | environment card/button opens the environment dashboard | none | none | workspace switch or no-access guidance only | none | `N/A` | no new audit behavior required by this slice | Reuses the existing chooser pattern; selection must not jump into a second panel | +| Managed-environment dashboard | `TenantDashboard` under `/admin/workspaces/{workspace}/environments/{environment}` | `Primary follow-up`, `More`, `Request support`, `Open support diagnostics` | `N/A` | none | none | existing dashboard empty-state behavior only | same as header actions | `N/A` | existing support-request audit behavior only | Action hierarchy and support flows stay intact; route and breadcrumb ownership change only | +| Workspace operations hub | `Monitoring\Operations` under `/admin/workspaces/{workspace}/operations` | `Scope operations`, `Back to ...`, `Show all environments` | clickable row to run detail | none | none | existing operations empty-state only | existing run-viewer contract only | `N/A` | no new audit behavior | Filter and back-link context replace environment-panel routing; no new bulk or destructive actions | + +All other environment-scoped resources and diagnostic pages that today live under `panel: 'tenant'` or `/admin/tenants/{environment}` keep their current local action contracts. This slice changes their route ownership and breadcrumb shell only and must not introduce new redundant View actions, placeholder action groups, or new destructive actions. + +### Key Entities *(include if feature involves data)* + +- **Workspace**: the operator's primary admin context and the only Filament tenant after this cutover; owns the canonical workspace dashboard, environment chooser, and workspace operations hub. +- **Managed Environment**: the environment-scoped managed target inside one workspace; owns the canonical environment dashboard route and remains the nested context for environment-scoped pages and explicit environment filters. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All touched operator entry and drillthrough flows open workspace-first environment URLs, and no operator-visible `/admin/t/` link remains on touched admin surfaces. +- **SC-002**: An operator can go from workspace selection to one managed-environment dashboard and then into the workspace operations hub with preserved scope in three interactions or fewer. +- **SC-003**: All touched environment-scoped pages present breadcrumb/context order `Workspace -> Managed Environment -> page`, and direct wrong-workspace or wrong-environment access reveals no data. +- **SC-004**: Touched globally searchable resources either open a valid destination on first attempt under the new route family or no longer appear in global search. diff --git a/specs/280-workspace-tenancy-environment-routing/tasks.md b/specs/280-workspace-tenancy-environment-routing/tasks.md new file mode 100644 index 00000000..2bbb08fb --- /dev/null +++ b/specs/280-workspace-tenancy-environment-routing/tasks.md @@ -0,0 +1,221 @@ +--- +description: "Task list for Filament Workspace Tenancy & Environment Routing Cutover" +--- + +# Tasks: Filament Workspace Tenancy & Environment Routing Cutover + +**Input**: Design documents from `specs/280-workspace-tenancy-environment-routing/` +**Prerequisites**: `specs/280-workspace-tenancy-environment-routing/spec.md`, `specs/280-workspace-tenancy-environment-routing/plan.md`, `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md`, `specs/280-workspace-tenancy-environment-routing/research.md`, `specs/280-workspace-tenancy-environment-routing/data-model.md`, `specs/280-workspace-tenancy-environment-routing/quickstart.md`, `specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` + +**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php`, `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php`, `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php`, `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php`, `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php`, and `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`. +**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Filament/Pages/Monitoring/Operations.php` for workspace-first operations route ownership. +**RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and in-scope capability denials stay `403`. +**Shared Pattern Reuse**: Reuse `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `ManagedTenantsLanding`, `ChooseTenant`, `WorkspaceRedirectResolver`, `OperationRunLinks`, and `RelatedNavigationResolver`. Do not add compatibility routes, dual-panel fallbacks, or replacement dashboards. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `Workspace` becomes the only Filament tenant for operator routing. Any touched searchable resource must keep a valid view/edit destination or disable global search in the same slice. Any touched destructive action must preserve `->requiresConfirmation()` plus current server authorization. Asset strategy stays unchanged. +**Compatibility Posture**: Reject `/admin/t` compatibility routes, `/admin/tenants/{environment}` aliases, redirect shims, dual-panel ownership, and hidden route fallbacks. Keep Specs `281` through `287` deferred. +**Organization**: Tasks are grouped by user story so panel tenancy, workspace-first environment routing, workspace-first operations routing, dashboard/context rebinding, and search/legacy guardrails remain independently testable. +**Review Outcome**: `implementation-ready` +**Workflow Outcome**: `keep` +**Test-governance Outcome**: `keep` + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane. +- [x] New or changed tests stay in the smallest honest families under `apps/platform/tests/Feature/` plus one browser smoke file only. +- [x] Workspace and managed-environment fixtures remain explicit; no compatibility fixtures, dual-panel defaults, or hidden route fallbacks become shared setup. +- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly. +- [x] `standard-native-filament`, `global-context-shell`, and `monitoring-state-page` expectations stay explicit for touched surfaces. +- [x] Any attempt to absorb Specs `281` through `287` resolves as `split` or `reject-or-split`, not hidden scope. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded cutover inventory, the proving files, and the explicit no-compatibility posture before runtime edits begin. + +- [ ] T001 Review `specs/280-workspace-tenancy-environment-routing/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` together so implementation stays on Spec 280 only. +- [ ] T002 [P] Confirm the current panel-provider and registration seams in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and `apps/platform/bootstrap/providers.php` before changing operator tenancy. +- [ ] T003 [P] Confirm the current entry, chooser, and route-language seams in `apps/platform/routes/web.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`. +- [ ] T004 [P] Confirm the current context-classification seams in `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php`. +- [ ] T005 [P] Confirm the current dashboard and operations link owners in `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`. +- [ ] T006 [P] Confirm the touched global-search and deferred-scope surfaces in `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` so Specs `281` through `287` remain explicitly out of scope. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the proving suite and the shared workspace-first route skeleton that all stories depend on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [ ] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` for `Workspace` as the only Filament tenant, `/admin` entry ownership, `TenantPanelProvider` retirement from public operator routing, and provider registration expectations in `apps/platform/bootstrap/providers.php`. +- [ ] T008 [P] Add failing coverage in `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` for `/admin/workspaces/{workspace}/environments`, `/admin/workspaces/{workspace}/environments/{environment}`, stale cross-workspace environment clearing, archived-environment exclusion, and wrong-workspace `404` behavior. +- [ ] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` for `/admin/workspaces/{workspace}/operations`, `managed_environment_id` filtering, `Show all environments` widening, workspace-safe run detail routes, and hostile filter `404` behavior. +- [ ] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` for workspace-dashboard versus environment-dashboard signal ownership and `Workspace -> Managed Environment -> page` breadcrumb/context ordering. +- [ ] T011 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, `/admin/operations/{run}`, `panel: 'tenant'`, `TenantPanelProvider::class` registration in `apps/platform/bootstrap/providers.php`, compatibility redirects, aliases, dual-panel fallbacks, and the searchable-destination rule for touched resources. +- [ ] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php` for workspace selection, workspace-scoped environment choice, managed-environment dashboard entry, and workspace-operations drillthrough on the surviving admin panel. +- [ ] T013 Establish the one-panel workspace-first route skeleton in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, and `apps/platform/routes/web.php` with no compatibility aliases, redirect shims, or dual-panel fallback. +- [ ] T014 Update `apps/platform/app/Filament/Pages/ChooseWorkspace.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` so `/admin` resolves only to workspace selection or `/admin/workspaces/{workspace}` before story-specific environment routing work begins. + +**Checkpoint**: The proving files exist, `/admin` entry ownership is workspace-first, and the implementation has a single admin-panel route skeleton to extend. + +--- + +## Phase 3: User Story 1 - Enter an environment without leaving the workspace admin panel (Priority: P1) + +**Goal**: Operators choose a workspace, then open one managed environment through the surviving admin panel and land on the canonical workspace-first environment dashboard. + +**Independent Test**: Select a workspace, open `/admin/workspaces/{workspace}/environments`, choose one managed environment, and confirm the destination is `/admin/workspaces/{workspace}/environments/{environment}` while `/admin/t/{environment}` stays not found. + +### Tests for User Story 1 + +- [ ] T015 [P] [US1] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` after T013-T014 to prove the public chooser and environment entry stay on the `admin` panel and direct `/admin/t/{environment}` requests return `404`. +- [ ] T016 [P] [US1] Extend `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` after T013-T014 to prove chooser submission, managed-environment dashboard resolution, and wrong-workspace route binding remain `404`. + +### Implementation for User Story 1 + +- [ ] T017 [US1] Rework `apps/platform/app/Filament/Pages/ChooseTenant.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` so `/admin/workspaces/{workspace}/environments` is the only public environment chooser and stale cross-workspace remembered environment context is cleared before resolution. +- [ ] T018 [US1] Move managed-environment dashboard and required-permissions route ownership in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and `apps/platform/routes/web.php` to `/admin/workspaces/{workspace}/environments/{environment}` with no `/admin/tenants/{environment}` compatibility reader. +- [ ] T019 [US1] Update workspace-to-environment URL generation in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and any touched environment page classes under `apps/platform/app/Filament/Pages/` so no entry flow emits `panel: 'tenant'` or `/admin/t` destinations. + +**Checkpoint**: Workspace selection, environment chooser entry, and managed-environment dashboard routing all stay inside one workspace-first admin panel. + +--- + +## Phase 4: User Story 2 - Move from environment work into workspace operations with explicit scope (Priority: P1) + +**Goal**: Environment dashboards and environment pages open the workspace operations hub through canonical workspace routes while preserving explicit environment context. + +**Independent Test**: From a managed-environment dashboard or touched environment page, open operations and confirm the destination is `/admin/workspaces/{workspace}/operations` with the correct `managed_environment_id` filter and workspace-safe back-navigation. + +### Tests for User Story 2 + +- [ ] T020 [P] [US2] Extend `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` to prove environment dashboards and touched environment pages open `/admin/workspaces/{workspace}/operations` with explicit `managed_environment_id`, preserve run-detail ownership under `/admin/workspaces/{workspace}/operations/{run}`, widen scope only through explicit user action, and keep `/admin/operations` plus `/admin/operations/{run}` unavailable. + +### Implementation for User Story 2 + +- [ ] T021 [US2] Retarget `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` so operations collection/detail links emit only workspace-first routes with explicit environment filter and return-context data. +- [ ] T022 [US2] Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` so workspace collection/detail ownership, `managed_environment_id` hydration, `Show all environments` behavior, and hostile filter handling match the new workspace-first route contract. +- [ ] T023 [US2] Update operations entry actions in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and any touched environment-scoped page classes under `apps/platform/app/Filament/Pages/` so they delegate through the shared workspace operations builders instead of local tenant-panel URLs. + +**Checkpoint**: Operations links, run-detail links, and return context are all workspace-canonical while preserving explicit environment scope. + +--- + +## Phase 5: User Story 3 - Read workspace-wide and environment-scoped signals on the correct surfaces (Priority: P2) + +**Goal**: Workspace-wide signals stay on the workspace dashboard, environment-scoped signals stay on the managed-environment dashboard, and breadcrumbs/context bars reflect the new route ownership. + +**Independent Test**: Open `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` for the same workspace and verify the workspace dashboard shows workspace-wide signals while the environment dashboard stays scoped to one environment with `Workspace -> Managed Environment -> page` context. + +### Tests for User Story 3 + +- [ ] T024 [P] [US3] Extend `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` to prove workspace-wide dashboard signals remain on `WorkspaceOverview`, environment-scoped signals remain on `TenantDashboard`, and breadcrumb/context ordering becomes `Workspace -> Managed Environment -> page`. + +### Implementation for User Story 3 + +- [ ] T025 [US3] Rebind `apps/platform/app/Filament/Pages/WorkspaceOverview.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` to the canonical `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` routes while preserving `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership. +- [ ] T026 [US3] Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php` so workspace-first environment routes are the only active environment-bound language and remembered cross-workspace environment context cannot leak. +- [ ] T027 [US3] Update context bars, breadcrumbs, and chooser/dashboard CTA links in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php` so the new route ownership reads `Workspace -> Managed Environment -> domain page` everywhere this slice touches. + +**Checkpoint**: Workspace dashboard, managed-environment dashboard, and current-context shells all present the correct scope and breadcrumb truth. + +--- + +## Phase 6: User Story 4 - Keep search and authorization truthful after the route cutover (Priority: P3) + +**Goal**: Searchable resources, direct URLs, and legacy-route guards stay truthful after the cutover, with no broken search results and no hidden compatibility routes. + +**Independent Test**: Open touched global-search results and direct workspace/environment URLs, confirm authorized destinations resolve under the workspace-first routes, and confirm `/admin/t/{environment}` plus `/admin/tenants/{environment}/required-permissions` remain `404`. + +### Tests for User Story 4 + +- [ ] T028 [P] [US4] Extend `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` to prove no compatibility routes, aliases, redirects, or dual-panel fallbacks survive for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}`. +- [ ] T029 [P] [US4] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` and `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` to cover `WorkspaceResource` and `TenantResource` global-search destinations plus `404` versus `403` behavior for direct workspace/environment URLs. + +### Implementation for User Story 4 + +- [ ] T030 [US4] Update `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` and `apps/platform/app/Filament/Resources/TenantResource.php` so each touched resource keeps a valid view/edit destination under workspace-first routing or disables global search in the same slice. +- [ ] T031 [US4] Remove remaining legacy-route ownership and panel-language fallbacks from `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/routes/web.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and any touched helpers under `apps/platform/tests/` so Specs `281` through `287` remain deferred instead of absorbed. + +**Checkpoint**: Search, direct URLs, and no-legacy route guards all reflect the final workspace-first contract with no hidden fallback path. + +--- + +## Phase 7: Polish & Cross-Cutting Validation + +**Purpose**: Run the exact bounded proof set, perform the final Filament review, and close the cutover without reopening deferred specs. + +- [ ] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php)`. +- [ ] T033 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)`. +- [ ] T034 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`. +- [ ] T035 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and confirm only intentional removal-guard output remains. +- [ ] T036 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` and confirm only intentional removal-guard output remains. +- [ ] T037 [P] Review `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and touched Filament pages/actions to confirm Filament v5 / Livewire v4 compliance, provider registration stays in `apps/platform/bootstrap/providers.php`, the global-search destination rule is satisfied, touched destructive actions still preserve `->requiresConfirmation()` plus authorization, and no asset strategy or deploy-step change was introduced. +- [ ] T038 [P] Record the implementation close-out in `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` or the active PR notes confirming no compatibility routes, aliases, redirects, or dual-panel fallback shipped and Specs `281` through `287` remain explicitly deferred. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical environment-entry contract. +- **Phase 4 (US2)**: depends on Phase 2 and should follow US1 route ownership so operations links target the finished workspace-first environment family. +- **Phase 5 (US3)**: depends on US1 and should land with or immediately after US2 so dashboards, breadcrumbs, and middleware all reflect the same route contract. +- **Phase 6 (US4)**: depends on US1 through US3 so search, authorization, and no-legacy guards prove the final route language rather than an intermediate state. +- **Phase 7 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and is the first required implementation increment. +- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because operations links rely on the canonical workspace-first environment family. +- **US3 (P2)**: independently testable after US1 and should land before US4 so breadcrumb and context truth are final. +- **US4 (P3)**: independently testable after US1 through US3 and closes search, authorization, and legacy-route honesty. + +### Within Each User Story + +- Write or extend the listed Pest coverage first and make it fail for the intended gap. +- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `281` through `287`. +- Re-run the narrowest relevant validation command for that story before moving to the next story. + +## Parallel Execution Examples + +- **Setup**: T002 through T006 can run in parallel once T001 sets the bounded scope. +- **Foundational**: T007 through T012 can run in parallel before T013 and T014 converge the route skeleton. +- **US1**: T015 and T016 can run in parallel; T017 through T019 should merge serially around chooser and route-owner files. +- **US2**: T020 can run alongside T021, then T022 and T023 should follow once shared operations builders are updated. +- **US3**: T024 can run in parallel with T025, then T026 and T027 should merge serially around the shared middleware and page-context seams. +- **US4**: T028 and T029 can run in parallel; T030 and T031 should follow once the final route contract is stable. +- **Polish**: T032 through T037 can run in parallel after implementation is complete; T038 should close out last. + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1**. Land the single surviving admin panel plus workspace-first environment entry before widening into operations, dashboard-context hardening, and search/no-legacy enforcement. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 so environment entry no longer depends on `/admin/t`. +3. Deliver US2 so operations links become workspace-canonical with explicit environment scope. +4. Deliver US3 so dashboard ownership, breadcrumbs, and middleware all converge on the same workspace-first context. +5. Deliver US4 to lock search, authorization, and legacy-route honesty. +6. Finish with the exact validation commands and the final Filament review in Phase 7. + +### Team Strategy + +1. Parallelize the failing test work first. +2. Serialize merges around `apps/platform/app/Providers/Filament/`, `apps/platform/routes/web.php`, and the middleware/context helpers to avoid conflicting route-language edits. +3. Reject any implementation branch that introduces compatibility routes, dual-panel ownership, or hidden follow-up work from Specs `281` through `287`. + +## Deferred Follow-Ups / Non-Goals + +- Spec `281` provider connection, provider scope, and Microsoft profile extraction +- Spec `282` governance artifact retargeting to `ManagedEnvironment` +- Spec `283` provider capability registry work +- Spec `284` provider-neutral artifact source taxonomy work +- Spec `285` workspace-first RBAC and environment access redesign +- Spec `286` UI copy, IA, and localization neutralization beyond route/context truth +- Spec `287` cutover quality gates and broader no-legacy enforcement beyond this feature-local proof \ No newline at end of file From 210508db9d6f7f664bc8a8e6d0b38cf86f026825 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 7 May 2026 13:12:17 +0000 Subject: [PATCH 2/4] feat: implement workspace and tenant closure lifecycle (#337) ## Summary - add explicit workspace closure and tenant removal lifecycle truth with a bounded `WorkspaceLifecycleService` - surface closure and removal posture across admin/system pages, chooser recovery, and canonical historical viewers - block new review-pack and operation starts for closed workspaces or removed tenants while preserving memberships, audit, and history - add focused Pest coverage plus the Spec 292 artifacts for the implemented slice ## Testing - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - manual integrated-browser smoke for admin tenant remove/restore plus chooser recovery and system workspace close/reopen Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/337 --- .../app/Filament/Pages/ChooseWorkspace.php | 6 +- .../Pages/Settings/WorkspaceSettings.php | 23 ++ .../app/Filament/Resources/TenantResource.php | 170 +++++++- .../TenantResource/Pages/ViewTenant.php | 4 +- .../Workspaces/Pages/ViewWorkspace.php | 4 +- .../Workspaces/WorkspaceResource.php | 47 ++- .../System/Pages/Directory/Tenants.php | 13 + .../System/Pages/Directory/ViewWorkspace.php | 77 +++- .../System/Pages/Directory/Workspaces.php | 9 + .../Middleware/EnsureWorkspaceSelected.php | 19 +- .../app/Models/ManagedEnvironment.php | 40 +- apps/platform/app/Models/Workspace.php | 42 ++ .../WorkspaceCommercialLifecycleResolver.php | 64 +++ .../app/Services/OperationRunService.php | 19 + .../app/Services/ReviewPackService.php | 5 +- .../Tenants/TenantOperabilityService.php | 60 ++- .../Workspaces/WorkspaceLifecycleService.php | 284 ++++++++++++++ .../app/Support/Audit/AuditActionId.php | 12 + .../app/Support/Auth/PlatformCapabilities.php | 2 + .../app/Support/Badges/BadgeCatalog.php | 2 + .../app/Support/Badges/BadgeDomain.php | 2 + .../Domains/TenantWorkspacePostureBadge.php | 22 ++ .../Domains/WorkspaceClosurePostureBadge.php | 22 ++ .../Middleware/DenyNonMemberTenantAccess.php | 10 + .../EnsureFilamentTenantSelected.php | 14 + .../ReferencedTenantLifecyclePresentation.php | 22 +- .../Tenants/TenantOperabilityReasonCode.php | 14 +- .../Support/Workspaces/WorkspaceContext.php | 8 +- ...add_closure_fields_to_workspaces_table.php | 28 ++ ...l_fields_to_managed_environments_table.php | 37 ++ .../filament/pages/choose-workspace.blade.php | 2 +- .../pages/directory/view-tenant.blade.php | 16 + .../pages/directory/view-workspace.blade.php | 28 +- .../system/pages/ops/view-run.blade.php | 28 ++ .../WorkspaceContextClosureRecoveryTest.php | 70 ++++ .../TenantWorkspaceRemovalTest.php | 120 ++++++ .../Workspaces/WorkspaceClosureStatusTest.php | 52 +++ .../Directory/ViewWorkspaceClosureTest.php | 145 +++++++ .../ClosedWorkspaceHistoricalAccessTest.php | 69 ++++ specs/292-workspace-tenant-closure/plan.md | 302 ++++++++++++++ specs/292-workspace-tenant-closure/spec.md | 370 ++++++++++++++++++ specs/292-workspace-tenant-closure/tasks.md | 182 +++++++++ 42 files changed, 2432 insertions(+), 33 deletions(-) create mode 100644 apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php create mode 100644 apps/platform/app/Support/Badges/Domains/TenantWorkspacePostureBadge.php create mode 100644 apps/platform/app/Support/Badges/Domains/WorkspaceClosurePostureBadge.php create mode 100644 apps/platform/database/migrations/2026_05_07_000000_add_closure_fields_to_workspaces_table.php create mode 100644 apps/platform/database/migrations/2026_05_07_000001_add_workspace_removal_fields_to_managed_environments_table.php create mode 100644 apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php create mode 100644 apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php create mode 100644 apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php create mode 100644 apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php create mode 100644 apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php create mode 100644 specs/292-workspace-tenant-closure/plan.md create mode 100644 specs/292-workspace-tenant-closure/spec.md create mode 100644 specs/292-workspace-tenant-closure/tasks.md diff --git a/apps/platform/app/Filament/Pages/ChooseWorkspace.php b/apps/platform/app/Filament/Pages/ChooseWorkspace.php index 634eb2e6..07f1ebe5 100644 --- a/apps/platform/app/Filament/Pages/ChooseWorkspace.php +++ b/apps/platform/app/Filament/Pages/ChooseWorkspace.php @@ -63,8 +63,10 @@ public function getWorkspaces(): Collection ->where('user_id', $user->getKey()); }) ->whereNull('archived_at') + ->whereNull('closed_at') ->withCount(['tenants' => function ($query): void { - $query->where('lifecycle_status', 'active'); + $query->where('lifecycle_status', 'active') + ->whereNull('removed_from_workspace_at'); }]) ->orderBy('name') ->get(); @@ -94,7 +96,7 @@ public function selectWorkspace(int $workspaceId): void abort(404); } - if (! empty($workspace->archived_at)) { + if (! $workspace->isSelectableAsContext()) { abort(404); } diff --git a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php index 5c9f7a3a..31861fd9 100644 --- a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -255,6 +255,21 @@ public function content(Schema $schema): Schema ->content(fn (): string => $this->commercialPostureReasonText()) ->columnSpanFull(), ]), + Section::make('Workspace lifecycle') + ->description('Read-only workspace closure posture. Closed workspaces keep history visible but block new tenant and workspace mutations.') + ->columns(2) + ->schema([ + Placeholder::make('workspace_closure_posture') + ->label('Lifecycle') + ->content(fn (): string => $this->workspace->isClosed() ? 'Closed' : 'Open'), + Placeholder::make('workspace_closed_at') + ->label('Closed at') + ->content(fn (): string => $this->workspace->closed_at?->toDayDateTimeString() ?? 'Not closed'), + Placeholder::make('workspace_closure_reason') + ->label('Closure reason') + ->content(fn (): string => $this->workspace->closureReason() ?? 'Not closed') + ->columnSpanFull(), + ]), Section::make('Support access approval') ->description('Review current support-access posture and decide pending workspace recovery requests.') ->columns(2) @@ -1740,6 +1755,7 @@ private function currentUserCanManage(): bool $resolver = app(WorkspaceCapabilityResolver::class); return $resolver->isMember($user, $this->workspace) + && ! $this->workspace->isClosed() && $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE); } @@ -1755,6 +1771,7 @@ private function currentUserCanApproveSupportAccess(): bool $resolver = app(WorkspaceCapabilityResolver::class); return $resolver->isMember($user, $this->workspace) + && ! $this->workspace->isClosed() && $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE) && $resolver->getRole($user, $this->workspace) === WorkspaceRole::Owner; } @@ -1785,5 +1802,11 @@ private function authorizeWorkspaceManage(User $user): void if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) { abort(403); } + + if ($this->workspace->isClosed()) { + throw ValidationException::withMessages([ + 'workspace' => 'This workspace is closed. Reopen it before changing workspace settings.', + ]); + } } } diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index b90bafa5..77236daa 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -33,6 +33,7 @@ use App\Services\Tenants\TenantActionPolicySurface; use App\Services\Tenants\TenantOperabilityService; use App\Services\Verification\StartVerification; +use App\Services\Workspaces\WorkspaceLifecycleService; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Auth\UiTooltips; @@ -147,6 +148,8 @@ public static function canEdit(Model $record): bool $resolver = app(CapabilityResolver::class); return $record instanceof ManagedEnvironment + && ! $record->isRemovedFromWorkspace() + && ! $record->workspace?->isClosed() && $resolver->can($user, $record, Capabilities::TENANT_MANAGE); } @@ -162,6 +165,8 @@ public static function canDelete(Model $record): bool $resolver = app(CapabilityResolver::class); return $record instanceof ManagedEnvironment + && ! $record->isRemovedFromWorkspace() + && ! $record->workspace?->isClosed() && $resolver->can($user, $record, Capabilities::TENANT_DELETE); } @@ -195,7 +200,9 @@ public static function makeAdminConsentAction(): Actions\Action ->label('Grant admin consent') ->icon('heroicon-o-clipboard-document') ->url(fn (ManagedEnvironment $record): string => static::adminConsentUrl($record) ?? '#') - ->visible(fn (ManagedEnvironment $record): bool => static::adminConsentUrl($record) !== null) + ->visible(fn (ManagedEnvironment $record): bool => static::adminConsentUrl($record) !== null + && ! $record->isRemovedFromWorkspace() + && ! $record->workspace?->isClosed()) ->openUrlInNewTab(), ) ->preserveVisibility() @@ -400,9 +407,115 @@ public static function makeArchiveTenantAction(TenantActionSurface $surface, ?st return $builder->apply(); } + public static function makeRemoveTenantFromWorkspaceAction(?string $permissionTooltip = null): Actions\Action + { + $builder = UiEnforcement::forAction( + Actions\Action::make('remove_from_workspace') + ->label('Remove tenant') + ->color('danger') + ->icon('heroicon-o-no-symbol') + ->requiresConfirmation() + ->modalHeading('Remove tenant from workspace') + ->modalDescription('The tenant remains available for audit, operation history, evidence, and administrative inspection, but it is no longer selectable as active tenant context.') + ->form([ + Forms\Components\Textarea::make('removal_reason') + ->label('Removal reason') + ->rows(4) + ->required() + ->minLength(5) + ->maxLength(2000), + ]) + ->visible(fn (ManagedEnvironment $record): bool => static::tenantWorkspaceRemovalActionVisible($record)) + ->disabled(fn (ManagedEnvironment $record): bool => (bool) $record->workspace?->isClosed()) + ->tooltip(fn (ManagedEnvironment $record): ?string => $record->workspace?->isClosed() + ? 'Closed workspaces are read-only. Reopen the workspace before removing tenants.' + : null) + ->action(function (ManagedEnvironment $record, array $data, WorkspaceLifecycleService $service): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($record)) { + abort(404); + } + + $service->removeTenantFromWorkspace($record, $user, (string) ($data['removal_reason'] ?? '')); + + Notification::make() + ->title('Tenant removed from workspace') + ->body('The tenant remains available for administrative inspection and historical evidence, but it is no longer selectable as active context.') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE); + + if ($permissionTooltip !== null && $permissionTooltip !== '') { + $builder->tooltip($permissionTooltip); + } + + return $builder->apply(); + } + + public static function makeRestoreTenantToWorkspaceAction(?string $permissionTooltip = null): Actions\Action + { + $builder = UiEnforcement::forAction( + Actions\Action::make('restore_to_workspace') + ->label('Restore tenant') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->modalHeading('Restore tenant to workspace') + ->modalDescription('Restoring the tenant makes it eligible for normal workspace tenant selection and new tenant operations again, subject to its lifecycle and RBAC.') + ->form([ + Forms\Components\Textarea::make('restore_reason') + ->label('Restore reason') + ->rows(4) + ->required() + ->minLength(5) + ->maxLength(2000), + ]) + ->visible(fn (ManagedEnvironment $record): bool => static::tenantWorkspaceRestoreActionVisible($record)) + ->disabled(fn (ManagedEnvironment $record): bool => (bool) $record->workspace?->isClosed()) + ->tooltip(fn (ManagedEnvironment $record): ?string => $record->workspace?->isClosed() + ? 'Closed workspaces are read-only. Reopen the workspace before restoring tenants.' + : null) + ->action(function (ManagedEnvironment $record, array $data, WorkspaceLifecycleService $service): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($record)) { + abort(404); + } + + $service->restoreTenantToWorkspace($record, $user, (string) ($data['restore_reason'] ?? '')); + + Notification::make() + ->title('Tenant restored to workspace') + ->body('The tenant can be selected again when its lifecycle and RBAC allow it.') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE); + + if ($permissionTooltip !== null && $permissionTooltip !== '') { + $builder->tooltip($permissionTooltip); + } + + return $builder->apply(); + } + private static function syncActionVisible(ManagedEnvironment $record): bool { - if (! $record->isActive()) { + if (! static::tenantSetupMutationVisible($record)) { return false; } @@ -635,6 +748,7 @@ public static function getEloquentQuery(): Builder return parent::getEloquentQuery() ->withTrashed() ->whereIn('id', $tenantIds) + ->with('workspace') ->withCount('policies') ->withMax('policies as last_policy_sync_at', 'last_synced_at'); } @@ -748,6 +862,17 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->description(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->shortDescription) ->sortable(), + Tables\Columns\TextColumn::make('workspace_posture') + ->label('Workspace posture') + ->badge() + ->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record)) + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture)) + ->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture)) + ->description(fn (ManagedEnvironment $record): ?string => $record->isRemovedFromWorkspace() + ? 'Hidden from active tenant context; historical records remain available.' + : null), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->since() @@ -869,6 +994,7 @@ public static function table(Table $table): Table Actions\Action::make('edit') ->label('Edit') ->icon('heroicon-o-pencil-square') + ->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record)) ->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record])) ) ->requireCapability(Capabilities::TENANT_MANAGE) @@ -942,6 +1068,7 @@ public static function table(Table $table): Table ); }), static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow), + static::makeRestoreTenantToWorkspaceAction(), static::rbacAction(), UiEnforcement::forAction( Actions\Action::make('forceDelete') @@ -999,6 +1126,7 @@ public static function table(Table $table): Table ->preserveVisibility() ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), + static::makeRemoveTenantFromWorkspaceAction(), static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow), ]) ->label('More') @@ -2352,9 +2480,19 @@ public static function infolist(Schema $schema): Schema ->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)), + Infolists\Components\TextEntry::make('workspace_posture') + ->label('Workspace posture') + ->badge() + ->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record)) + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture)) + ->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture)), Infolists\Components\TextEntry::make('lifecycle_summary') ->label('Lifecycle summary') - ->state(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->longDescription) + ->state(fn (ManagedEnvironment $record): string => $record->isRemovedFromWorkspace() + ? 'Removed from workspace. The tenant is hidden from active context and new tenant operations, while historical evidence and operations remain available.' + : static::tenantLifecyclePresentation($record)->longDescription) ->columnSpanFull(), ]) ->columns(2) @@ -2762,7 +2900,9 @@ public static function tenantEditContextHtml(?ManagedEnvironment $tenant): HtmlS public static function tenantViewLifecycleGroupVisible(ManagedEnvironment $tenant): bool { - return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true); + return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true) + || static::tenantWorkspaceRemovalActionVisible($tenant) + || static::tenantWorkspaceRestoreActionVisible($tenant); } public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant): bool @@ -2772,7 +2912,24 @@ public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant public static function tenantViewSetupGroupVisible(ManagedEnvironment $tenant): bool { - return $tenant->isActive(); + return static::tenantSetupMutationVisible($tenant); + } + + public static function tenantSetupMutationVisible(ManagedEnvironment $tenant): bool + { + return $tenant->isActive() + && ! $tenant->isRemovedFromWorkspace() + && ! $tenant->workspace?->isClosed(); + } + + public static function tenantWorkspaceRemovalActionVisible(ManagedEnvironment $tenant): bool + { + return ! $tenant->trashed() && ! $tenant->isRemovedFromWorkspace(); + } + + public static function tenantWorkspaceRestoreActionVisible(ManagedEnvironment $tenant): bool + { + return $tenant->isRemovedFromWorkspace(); } public static function verificationActionVisible(ManagedEnvironment $tenant): bool @@ -2812,6 +2969,7 @@ private static function tenantActionCatalogCacheKey(ManagedEnvironment $tenant, $surface->value, (string) ($tenant->getKey() ?? 'missing'), (string) $tenant->status, + (string) ($tenant->removed_from_workspace_at?->getTimestamp() ?? 'not-removed-from-workspace'), (string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'), (string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'), (string) ($relatedDraft?->getKey() ?? 'no-draft'), @@ -3059,7 +3217,7 @@ public static function rbacAction(): Actions\Action ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), ]) - ->visible(fn (ManagedEnvironment $record): bool => $record->isActive()) + ->visible(fn (ManagedEnvironment $record): bool => static::tenantSetupMutationVisible($record)) ->disabled(function (ManagedEnvironment $record): bool { $user = auth()->user(); diff --git a/apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index be2f6d44..bec06f66 100644 --- a/apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -74,7 +74,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-arrow-path') ->color('primary') ->requiresConfirmation() - ->visible(fn (ManagedEnvironment $record): bool => $record->isActive()) + ->visible(fn (ManagedEnvironment $record): bool => TenantResource::tenantSetupMutationVisible($record)) ->action(function (ManagedEnvironment $record): void { $user = auth()->user(); @@ -152,6 +152,8 @@ protected function getHeaderActions(): array && TenantResource::tenantViewTriageGroupVisible($this->getRecord())), Actions\ActionGroup::make([ TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader), + TenantResource::makeRestoreTenantToWorkspaceAction(), + TenantResource::makeRemoveTenantFromWorkspaceAction(), TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader), ]) ->label('Lifecycle') diff --git a/apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php b/apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php index 4b4384c6..7f145146 100644 --- a/apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php +++ b/apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php @@ -17,9 +17,11 @@ protected function getHeaderActions(): array { return [ WorkspaceUiEnforcement::forTableAction( - Actions\EditAction::make(), + Actions\EditAction::make() + ->visible(fn (): bool => WorkspaceResource::canEdit($this->record)), fn (): ?Workspace => $this->record, ) + ->preserveVisibility() ->requireCapability(Capabilities::WORKSPACE_MANAGE) ->apply(), ]; diff --git a/apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php b/apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php index 521c494a..1b17b536 100644 --- a/apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -6,7 +6,10 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\Workspaces\WorkspaceLifecycleService; use App\Support\Auth\Capabilities; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -18,6 +21,7 @@ use Filament\Actions; use Filament\Actions\ActionGroup; use Filament\Forms; +use Filament\Infolists; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; @@ -91,6 +95,7 @@ public static function canEdit(Model $record): bool $resolver = app(WorkspaceCapabilityResolver::class); return $resolver->isMember($user, $record) + && ! $record->isClosed() && $resolver->can($user, $record, Capabilities::WORKSPACE_MANAGE); } @@ -161,13 +166,26 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('slug') ->searchable() ->sortable(), + Tables\Columns\TextColumn::make('closure_posture') + ->label('Lifecycle') + ->badge() + ->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record)) + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture)) + ->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture)) + ->description(fn (Workspace $record): ?string => $record->isClosed() + ? 'Read-only; tenant context selection is disabled.' + : null), ]) ->actions([ ActionGroup::make([ WorkspaceUiEnforcement::forTableAction( - Actions\EditAction::make(), + Actions\EditAction::make() + ->visible(fn (Workspace $record): bool => static::canEdit($record)), fn (): ?Workspace => null, ) + ->preserveVisibility() ->requireCapability(Capabilities::WORKSPACE_MANAGE) ->apply(), ]) @@ -182,6 +200,33 @@ public static function table(Table $table): Table ]); } + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Infolists\Components\TextEntry::make('name'), + Infolists\Components\TextEntry::make('slug'), + Infolists\Components\TextEntry::make('closure_posture') + ->label('Lifecycle') + ->badge() + ->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record)) + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture)) + ->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture)), + Infolists\Components\TextEntry::make('closed_at') + ->label('Closed at') + ->dateTime() + ->placeholder('Not closed'), + Infolists\Components\TextEntry::make('closure_reason') + ->label('Closure reason') + ->state(fn (Workspace $record): ?string => $record->closureReason()) + ->placeholder('Not closed') + ->columnSpanFull(), + ]) + ->columns(2); + } + public static function getPages(): array { return [ diff --git a/apps/platform/app/Filament/System/Pages/Directory/Tenants.php b/apps/platform/app/Filament/System/Pages/Directory/Tenants.php index c704dbe1..cb73f1a3 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/Tenants.php +++ b/apps/platform/app/Filament/System/Pages/Directory/Tenants.php @@ -6,6 +6,7 @@ use App\Models\PlatformUser; use App\Models\ManagedEnvironment; +use App\Services\Workspaces\WorkspaceLifecycleService; use App\Support\Auth\PlatformCapabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; @@ -94,6 +95,14 @@ public function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)), + TextColumn::make('workspace_posture') + ->label('Workspace posture') + ->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record)) + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture)) + ->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture)), TextColumn::make('health') ->label('Health') ->state(fn (ManagedEnvironment $record): string => $this->healthForTenant($record)) @@ -110,6 +119,10 @@ public function table(Table $table): Table private function healthForTenant(ManagedEnvironment $tenant): string { + if ($tenant->isRemovedFromWorkspace()) { + return 'unknown'; + } + if ((string) $tenant->status === ManagedEnvironment::STATUS_ARCHIVED) { return 'unknown'; } diff --git a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php index eef8bb10..9cc5f552 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php +++ b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php @@ -17,6 +17,7 @@ use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceSubscriptionResolver; use App\Services\Settings\SettingsWriter; +use App\Services\Workspaces\WorkspaceLifecycleService; use App\Support\Auth\PlatformCapabilities; use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery; use App\Support\OperationCatalog; @@ -70,7 +71,7 @@ public function workspaceTenants(): Collection ->where('workspace_id', (int) $this->workspace->getKey()) ->orderBy('name') ->limit(10) - ->get(['id', 'name', 'lifecycle_status', 'workspace_id', 'slug']); + ->get(['id', 'name', 'lifecycle_status', 'workspace_id', 'slug', 'removed_from_workspace_at']); } /** @@ -123,6 +124,72 @@ public function workspaceCommercialLifecycleSummary(): array protected function getHeaderActions(): array { return [ + Action::make('close_workspace') + ->label('Close workspace') + ->icon('heroicon-o-lock-closed') + ->color('danger') + ->visible(fn (): bool => $this->canManageWorkspaceLifecycle() && ! $this->workspace->isClosed()) + ->requiresConfirmation() + ->modalHeading('Close workspace') + ->modalDescription('Closing a workspace removes it from active workspace selection and blocks new workspace and tenant mutations while preserving history.') + ->form([ + Textarea::make('reason') + ->label('Closure reason') + ->required() + ->minLength(5) + ->maxLength(2000) + ->rows(4), + ]) + ->action(function (array $data, WorkspaceLifecycleService $service): void { + $actor = auth('platform')->user(); + + if (! $actor instanceof PlatformUser) { + abort(403); + } + + $this->workspace = $service + ->closeWorkspace($this->workspace, $actor, (string) ($data['reason'] ?? '')) + ->fresh() + ->loadCount('tenants'); + + Notification::make() + ->title('Workspace closed') + ->success() + ->send(); + }), + Action::make('reopen_workspace') + ->label('Reopen workspace') + ->icon('heroicon-o-lock-open') + ->color('success') + ->visible(fn (): bool => $this->canManageWorkspaceLifecycle() && $this->workspace->isClosed()) + ->requiresConfirmation() + ->modalHeading('Reopen workspace') + ->modalDescription('Reopening makes the workspace eligible for normal workspace selection and tenant operations again, subject to RBAC and tenant posture.') + ->form([ + Textarea::make('reason') + ->label('Reopen reason') + ->required() + ->minLength(5) + ->maxLength(2000) + ->rows(4), + ]) + ->action(function (array $data, WorkspaceLifecycleService $service): void { + $actor = auth('platform')->user(); + + if (! $actor instanceof PlatformUser) { + abort(403); + } + + $this->workspace = $service + ->reopenWorkspace($this->workspace, $actor, (string) ($data['reason'] ?? '')) + ->fresh() + ->loadCount('tenants'); + + Notification::make() + ->title('Workspace reopened') + ->success() + ->send(); + }), Action::make('request_support_access') ->label('Request support access') ->icon('heroicon-o-lifebuoy') @@ -397,6 +464,14 @@ private function canManageCommercialLifecycle(): bool && $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE); } + private function canManageWorkspaceLifecycle(): bool + { + $user = auth('platform')->user(); + + return $user instanceof PlatformUser + && $user->hasCapability(PlatformCapabilities::DIRECTORY_MANAGE); + } + private function canManageSupportAccess(): bool { $user = auth('platform')->user(); diff --git a/apps/platform/app/Filament/System/Pages/Directory/Workspaces.php b/apps/platform/app/Filament/System/Pages/Directory/Workspaces.php index 9d82c83e..7ff20404 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/Workspaces.php +++ b/apps/platform/app/Filament/System/Pages/Directory/Workspaces.php @@ -8,6 +8,7 @@ use App\Models\PlatformUser; use App\Models\ManagedEnvironment; use App\Models\Workspace; +use App\Services\Workspaces\WorkspaceLifecycleService; use App\Support\Auth\PlatformCapabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; @@ -81,6 +82,14 @@ public function table(Table $table): Table ->searchable(), TextColumn::make('tenants_count') ->label('Tenants'), + TextColumn::make('closure_posture') + ->label('Lifecycle') + ->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record)) + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture)) + ->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture)) + ->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture)), TextColumn::make('health') ->label('Health') ->state(fn (Workspace $record): string => $this->healthForWorkspace($record)) diff --git a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php index 8d0e100c..04cfb06c 100644 --- a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -77,7 +77,7 @@ public function handle(Request $request, Closure $next): Response if ( $workspace instanceof Workspace - && empty($workspace->archived_at) + && $workspace->isSelectableAsContext() && $context->isMember($user, $workspace) ) { return $next($request); @@ -85,7 +85,7 @@ public function handle(Request $request, Closure $next): Response $this->clearStaleSession($context, $user, $request, $workspace); - if ($workspace instanceof Workspace && empty($workspace->archived_at)) { + if ($workspace instanceof Workspace && $workspace->isSelectableAsContext()) { abort(404); } @@ -97,6 +97,7 @@ public function handle(Request $request, Closure $next): Response ->where('user_id', $user->getKey()) ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') ->whereNull('workspaces.archived_at') + ->whereNull('workspaces.closed_at') ->select('workspace_memberships.*') ->get(); @@ -135,7 +136,7 @@ public function handle(Request $request, Closure $next): Response if ( $lastWorkspace instanceof Workspace - && empty($lastWorkspace->archived_at) + && $lastWorkspace->isSelectableAsContext() && $context->isMember($user, $lastWorkspace) ) { $context->setCurrentWorkspace($lastWorkspace, $user, $request); @@ -160,8 +161,12 @@ public function handle(Request $request, Closure $next): Response $user->forceFill(['last_workspace_id' => null])->save(); if ($workspaceName !== null) { + $message = $lastWorkspace?->isClosed() + ? "The workspace {$workspaceName} was closed." + : "Your access to {$workspaceName} was removed."; + Notification::make() - ->title("Your access to {$workspaceName} was removed.") + ->title($message) ->danger() ->send(); } @@ -266,8 +271,12 @@ private function clearStaleSession(WorkspaceContext $context, User $user, Reques } if ($workspaceName !== null) { + $message = $workspace?->isClosed() + ? "The workspace {$workspaceName} was closed." + : "Your access to {$workspaceName} was removed."; + Notification::make() - ->title("Your access to {$workspaceName} was removed.") + ->title($message) ->danger() ->send(); } diff --git a/apps/platform/app/Models/ManagedEnvironment.php b/apps/platform/app/Models/ManagedEnvironment.php index c2779bfd..c4d83ec3 100644 --- a/apps/platform/app/Models/ManagedEnvironment.php +++ b/apps/platform/app/Models/ManagedEnvironment.php @@ -38,6 +38,7 @@ class ManagedEnvironment extends Model implements HasName 'metadata' => 'array', 'is_current' => 'boolean', 'rbac_last_checked_at' => 'datetime', + 'removed_from_workspace_at' => 'datetime', ]; public function getExternalIdAttribute(): ?string @@ -241,7 +242,11 @@ public static function activeQuery(): Builder { return static::query() ->whereNull('deleted_at') - ->where('lifecycle_status', TenantLifecycle::Active->value); + ->whereNull('removed_from_workspace_at') + ->where('lifecycle_status', TenantLifecycle::Active->value) + ->whereHas('workspace', fn (Builder $query): Builder => $query + ->whereNull('archived_at') + ->whereNull('closed_at')); } public static function skipTestWorkspaceProvisioning(bool $skip = true): void @@ -336,6 +341,11 @@ public function workspace(): BelongsTo return $this->belongsTo(Workspace::class); } + public function removedFromWorkspaceByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'removed_from_workspace_by_user_id'); + } + public function roleMappings(): HasMany { return $this->hasMany(TenantRoleMapping::class); @@ -552,11 +562,35 @@ public function isArchived(): bool public function isSelectableAsContext(): bool { - return ! $this->trashed() && $this->lifecycle()->canSelectAsContext(); + if ($this->trashed() || $this->isRemovedFromWorkspace() || ! $this->lifecycle()->canSelectAsContext()) { + return false; + } + + if ($this->workspace_id === null) { + return false; + } + + $workspace = $this->relationLoaded('workspace') + ? $this->workspace + : $this->workspace()->first(['id', 'archived_at', 'closed_at']); + + return $workspace instanceof Workspace && $workspace->isSelectableAsContext(); } public function canResumeOnboarding(): bool { - return ! $this->trashed() && $this->lifecycle()->canResumeOnboarding(); + return ! $this->trashed() && ! $this->isRemovedFromWorkspace() && $this->lifecycle()->canResumeOnboarding(); + } + + public function isRemovedFromWorkspace(): bool + { + return $this->removed_from_workspace_at !== null; + } + + public function workspaceRemovalReason(): ?string + { + $reason = trim((string) $this->removed_from_workspace_reason); + + return $reason === '' ? null : $reason; } } diff --git a/apps/platform/app/Models/Workspace.php b/apps/platform/app/Models/Workspace.php index 9151d5d2..1e325392 100644 --- a/apps/platform/app/Models/Workspace.php +++ b/apps/platform/app/Models/Workspace.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -15,6 +16,25 @@ class Workspace extends Model protected $guarded = []; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'archived_at' => 'datetime', + 'closed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function closedByPlatformUser(): BelongsTo + { + return $this->belongsTo(PlatformUser::class, 'closed_by_platform_user_id'); + } + /** * @return HasMany */ @@ -81,4 +101,26 @@ public function tenantSettings(): HasMany { return $this->hasMany(TenantSetting::class); } + + public function isClosed(): bool + { + return $this->closed_at !== null; + } + + public function isArchived(): bool + { + return $this->archived_at !== null; + } + + public function isSelectableAsContext(): bool + { + return ! $this->isArchived() && ! $this->isClosed(); + } + + public function closureReason(): ?string + { + $reason = trim((string) $this->closed_reason); + + return $reason === '' ? null : $reason; + } } diff --git a/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php index e28665ae..4362cc58 100644 --- a/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php +++ b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php @@ -55,6 +55,10 @@ final class WorkspaceCommercialLifecycleResolver public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle'; + public const REASON_FAMILY_WORKSPACE_CLOSURE = 'workspace_closure'; + + public const REASON_FAMILY_TENANT_WORKSPACE_REMOVAL = 'tenant_workspace_removal'; + public function __construct( private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver, private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver, @@ -164,6 +168,9 @@ public function resolve(Workspace $workspace): array 'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null, 'subscription_key_date' => $subscriptionSummary['key_date'] ?? null, 'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false), + 'workspace_closed' => $workspace->isClosed(), + 'workspace_closed_at' => $workspace->closed_at, + 'workspace_closure_reason' => $workspace->closureReason(), ]; } @@ -192,6 +199,17 @@ public function reviewPackStartDecisionForTenant(ManagedEnvironment $tenant): ar { $tenant->loadMissing('workspace'); + if ($tenant->isRemovedFromWorkspace()) { + return $this->decision( + lifecycle: $this->resolve($tenant->workspace), + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_TENANT_WORKSPACE_REMOVAL, + message: 'This tenant was removed from the workspace. New review-pack starts are blocked until the tenant is restored to the workspace.', + substrateDecision: null, + ); + } + return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START); } @@ -201,6 +219,14 @@ public function reviewPackStartDecisionForTenant(ManagedEnvironment $tenant): ar */ private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array { + if ($workspace->isClosed()) { + return $this->closedWorkspaceDecision( + $lifecycle, + self::ACTION_MANAGED_TENANT_ACTIVATION, + 'This workspace is closed. New managed-tenant activation is blocked, while existing review, evidence, and operation history remains available under current RBAC.', + ); + } + $substrateDecision = $this->workspaceEntitlementResolver->resolve( $workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, @@ -251,6 +277,14 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li */ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array { + if ($workspace->isClosed()) { + return $this->closedWorkspaceDecision( + $lifecycle, + self::ACTION_REVIEW_PACK_START, + 'This workspace is closed. New review-pack starts are blocked, while existing review packs, evidence, and review history remain available under current RBAC.', + ); + } + $substrateDecision = $this->workspaceEntitlementResolver->resolve( $workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, @@ -301,6 +335,17 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle) */ private function readOnlyDecision(string $actionKey, array $lifecycle): array { + if ((bool) ($lifecycle['workspace_closed'] ?? false)) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: $actionKey, + outcome: self::OUTCOME_ALLOW_READ_ONLY, + reasonFamily: self::REASON_FAMILY_WORKSPACE_CLOSURE, + message: $this->lifecycleMessage($lifecycle, 'Closed workspaces keep existing review, evidence, generated-pack, and operation history available under current RBAC.'), + substrateDecision: null, + ); + } + if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) { return $this->decision( lifecycle: $lifecycle, @@ -330,6 +375,22 @@ private function lifecycleMessage(array $lifecycle, string $message): string return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle)); } + /** + * @param array $lifecycle + * @return array + */ + private function closedWorkspaceDecision(array $lifecycle, string $actionKey, string $message): array + { + return $this->decision( + lifecycle: array_merge($lifecycle, ['workspace_closed' => true]), + actionKey: $actionKey, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_WORKSPACE_CLOSURE, + message: $this->lifecycleMessage($lifecycle, $message), + substrateDecision: null, + ); + } + /** * @param array $lifecycle */ @@ -368,6 +429,9 @@ private function decision( 'source' => (string) $lifecycle['source'], 'source_label' => (string) $lifecycle['source_label'], 'rationale' => $lifecycle['rationale'] ?? null, + 'workspace_closed' => (bool) ($lifecycle['workspace_closed'] ?? false), + 'workspace_closed_at' => $lifecycle['workspace_closed_at'] ?? null, + 'workspace_closure_reason' => $lifecycle['workspace_closure_reason'] ?? null, 'entitlement_decision' => $substrateDecision, ]; } diff --git a/apps/platform/app/Services/OperationRunService.php b/apps/platform/app/Services/OperationRunService.php index cfb9efb0..13465b45 100644 --- a/apps/platform/app/Services/OperationRunService.php +++ b/apps/platform/app/Services/OperationRunService.php @@ -66,6 +66,19 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): return $run->created_at->lte(now()->subMinutes($thresholdMinutes)); } + private function assertTenantAllowsNewOperation(ManagedEnvironment $tenant): void + { + $tenant->loadMissing('workspace'); + + if ($tenant->isRemovedFromWorkspace()) { + throw new InvalidArgumentException('ManagedEnvironment was removed from the workspace; new operation runs are blocked until it is restored.'); + } + + if ($tenant->workspace?->isClosed()) { + throw new InvalidArgumentException('Workspace is closed; new operation runs are blocked until it is reopened.'); + } + } + public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun { return $this->forceFailNonTerminalRun( @@ -124,6 +137,8 @@ public function ensureRun( throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.'); } + $this->assertTenantAllowsNewOperation($tenant); + $hash = $this->calculateHash($tenant->id, $type, $inputs); // Idempotency Check (Fast Path) @@ -194,6 +209,8 @@ public function ensureRunWithIdentity( throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.'); } + $this->assertTenantAllowsNewOperation($tenant); + $hash = $this->calculateHash($tenant->id, $type, $identityInputs); // Idempotency Check (Fast Path) @@ -333,6 +350,8 @@ public function ensureRunWithIdentityStrict( throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.'); } + $this->assertTenantAllowsNewOperation($tenant); + $hash = $this->calculateHash($tenant->id, $type, $identityInputs); $existing = OperationRun::query() diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index a5293138..05119abb 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -263,10 +263,7 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s public function reviewPackGenerationDecisionForTenant(ManagedEnvironment $tenant): array { $tenant->loadMissing('workspace'); - $decision = $this->workspaceCommercialLifecycleResolver->actionDecision( - $tenant->workspace, - WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START, - ); + $decision = $this->workspaceCommercialLifecycleResolver->reviewPackStartDecisionForTenant($tenant); $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] diff --git a/apps/platform/app/Services/Tenants/TenantOperabilityService.php b/apps/platform/app/Services/Tenants/TenantOperabilityService.php index a0d34df4..be72ac51 100644 --- a/apps/platform/app/Services/Tenants/TenantOperabilityService.php +++ b/apps/platform/app/Services/Tenants/TenantOperabilityService.php @@ -112,6 +112,60 @@ public function evaluate(TenantOperabilityContext $context, TenantOperabilityQue ); } + if ( + $context->tenant->workspace?->isClosed() + && in_array($question, [ + TenantOperabilityQuestion::SelectorEligibility, + TenantOperabilityQuestion::RememberedContextValidity, + TenantOperabilityQuestion::ArchiveEligibility, + TenantOperabilityQuestion::RestoreEligibility, + TenantOperabilityQuestion::ResumeOnboardingEligibility, + TenantOperabilityQuestion::OnboardingCompletionEligibility, + TenantOperabilityQuestion::VerificationReadinessEligibility, + ], true) + ) { + return TenantOperabilityOutcome::deny( + question: $question, + lifecycle: $lifecycle, + lane: $context->lane, + reasonCode: TenantOperabilityReasonCode::WorkspaceClosed, + discoverable: in_array($question, [ + TenantOperabilityQuestion::ArchiveEligibility, + TenantOperabilityQuestion::RestoreEligibility, + TenantOperabilityQuestion::ResumeOnboardingEligibility, + TenantOperabilityQuestion::VerificationReadinessEligibility, + ], true), + requiredCapability: $context->requiredCapability, + metadata: $this->metadata($context), + ); + } + + if ( + $context->tenant->isRemovedFromWorkspace() + && in_array($question, [ + TenantOperabilityQuestion::SelectorEligibility, + TenantOperabilityQuestion::RememberedContextValidity, + TenantOperabilityQuestion::ArchiveEligibility, + TenantOperabilityQuestion::ResumeOnboardingEligibility, + TenantOperabilityQuestion::OnboardingCompletionEligibility, + TenantOperabilityQuestion::VerificationReadinessEligibility, + ], true) + ) { + return TenantOperabilityOutcome::deny( + question: $question, + lifecycle: $lifecycle, + lane: $context->lane, + reasonCode: TenantOperabilityReasonCode::TenantRemovedFromWorkspace, + discoverable: in_array($question, [ + TenantOperabilityQuestion::ArchiveEligibility, + TenantOperabilityQuestion::ResumeOnboardingEligibility, + TenantOperabilityQuestion::VerificationReadinessEligibility, + ], true), + requiredCapability: $context->requiredCapability, + metadata: $this->metadata($context), + ); + } + return match ($question) { TenantOperabilityQuestion::SelectorEligibility => $this->selectorEligibilityOutcome($context, $lifecycle), TenantOperabilityQuestion::RememberedContextValidity => $this->rememberedContextOutcome($context, $lifecycle), @@ -240,7 +294,11 @@ public function applySelectableScope(Builder $query, ?string $table = null): Bui return $query ->whereNull("{$prefix}deleted_at") - ->where("{$prefix}lifecycle_status", TenantLifecycle::Active->value); + ->whereNull("{$prefix}removed_from_workspace_at") + ->where("{$prefix}lifecycle_status", TenantLifecycle::Active->value) + ->whereHas('workspace', fn (Builder $workspaceQuery): Builder => $workspaceQuery + ->whereNull('archived_at') + ->whereNull('closed_at')); } public function applyAdministrativeDiscoverabilityScope(Builder $query, ?string $table = null): Builder diff --git a/apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php b/apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php new file mode 100644 index 00000000..d57b562b --- /dev/null +++ b/apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php @@ -0,0 +1,284 @@ +isClosed() + ? self::WORKSPACE_POSTURE_CLOSED + : self::WORKSPACE_POSTURE_OPEN; + } + + public function tenantPosture(ManagedEnvironment $tenant): string + { + return $tenant->isRemovedFromWorkspace() + ? self::TENANT_POSTURE_REMOVED + : self::TENANT_POSTURE_ACTIVE; + } + + public function assertWorkspaceMutationAllowed(Workspace $workspace): void + { + if (! $workspace->isClosed()) { + return; + } + + throw ValidationException::withMessages([ + 'workspace' => 'This workspace is closed. Reopen it before making workspace or tenant changes.', + ]); + } + + public function closeWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace + { + $this->authorizePlatformDirectoryManagement($actor); + $reason = $this->normalizeReason($reason); + + return DB::transaction(function () use ($workspace, $actor, $reason): Workspace { + $workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey()); + + if ($workspace->isClosed()) { + throw ValidationException::withMessages([ + 'reason' => 'This workspace is already closed.', + ]); + } + + $workspace->forceFill([ + 'closed_at' => now(), + 'closed_by_platform_user_id' => (int) $actor->getKey(), + 'closed_reason' => $reason, + ])->save(); + + ManagedEnvironment::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('is_current', true) + ->update(['is_current' => false]); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceClosed, + context: [ + 'reason' => $reason, + 'before_status' => self::WORKSPACE_POSTURE_OPEN, + 'after_status' => self::WORKSPACE_POSTURE_CLOSED, + 'closed_at' => $workspace->closed_at?->toISOString(), + ], + actor: $actor, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + targetLabel: (string) $workspace->name, + summary: 'Workspace closed for '.$workspace->name, + ); + + return $workspace; + }); + } + + public function reopenWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace + { + $this->authorizePlatformDirectoryManagement($actor); + $reason = $this->normalizeReason($reason); + + return DB::transaction(function () use ($workspace, $actor, $reason): Workspace { + $workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey()); + + if (! $workspace->isClosed()) { + throw ValidationException::withMessages([ + 'reason' => 'This workspace is already open.', + ]); + } + + $previousClosedAt = $workspace->closed_at; + $previousReason = $workspace->closureReason(); + $previousActorId = $workspace->closed_by_platform_user_id; + + $workspace->forceFill([ + 'closed_at' => null, + 'closed_by_platform_user_id' => null, + 'closed_reason' => null, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceReopened, + context: [ + 'reason' => $reason, + 'before_status' => self::WORKSPACE_POSTURE_CLOSED, + 'after_status' => self::WORKSPACE_POSTURE_OPEN, + 'previous_closed_at' => $previousClosedAt?->toISOString(), + 'previous_closed_reason' => $previousReason, + 'previous_closed_by_platform_user_id' => $previousActorId, + ], + actor: $actor, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + targetLabel: (string) $workspace->name, + summary: 'Workspace reopened for '.$workspace->name, + ); + + return $workspace; + }); + } + + public function removeTenantFromWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment + { + $reason = $this->normalizeReason($reason); + + return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment { + $tenant = ManagedEnvironment::query() + ->with(['workspace']) + ->withTrashed() + ->lockForUpdate() + ->findOrFail($tenant->getKey()); + + if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) { + throw new AuthorizationException('You are not allowed to remove this tenant from the workspace.'); + } + + if ($tenant->workspace?->isClosed()) { + $this->assertWorkspaceMutationAllowed($tenant->workspace); + } + + if ($tenant->isRemovedFromWorkspace()) { + throw ValidationException::withMessages([ + 'reason' => 'This tenant is already removed from the workspace.', + ]); + } + + $tenant->forceFill([ + 'removed_from_workspace_at' => now(), + 'removed_from_workspace_by_user_id' => (int) $actor->getKey(), + 'removed_from_workspace_reason' => $reason, + 'is_current' => false, + ])->save(); + + app(WorkspaceContext::class)->clearRememberedTenantContext(); + + $this->auditLogger->logTenantLifecycleAction( + tenant: $tenant, + action: AuditActionId::TenantRemovedFromWorkspace, + context: [ + 'reason' => $reason, + 'before_status' => self::TENANT_POSTURE_ACTIVE, + 'after_status' => self::TENANT_POSTURE_REMOVED, + 'removed_from_workspace_at' => $tenant->removed_from_workspace_at?->toISOString(), + ], + actor: $actor, + summary: 'ManagedEnvironment removed from workspace for '.$tenant->name, + ); + + return $tenant; + }); + } + + public function restoreTenantToWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment + { + $reason = $this->normalizeReason($reason); + + return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment { + $tenant = ManagedEnvironment::query() + ->with(['workspace']) + ->withTrashed() + ->lockForUpdate() + ->findOrFail($tenant->getKey()); + + if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) { + throw new AuthorizationException('You are not allowed to restore this tenant to the workspace.'); + } + + if ($tenant->workspace?->isClosed()) { + $this->assertWorkspaceMutationAllowed($tenant->workspace); + } + + if (! $tenant->isRemovedFromWorkspace()) { + throw ValidationException::withMessages([ + 'reason' => 'This tenant is not removed from the workspace.', + ]); + } + + $previousRemovedAt = $tenant->removed_from_workspace_at; + $previousReason = $tenant->workspaceRemovalReason(); + $previousActorId = $tenant->removed_from_workspace_by_user_id; + + $tenant->forceFill([ + 'removed_from_workspace_at' => null, + 'removed_from_workspace_by_user_id' => null, + 'removed_from_workspace_reason' => null, + ])->save(); + + $this->auditLogger->logTenantLifecycleAction( + tenant: $tenant, + action: AuditActionId::TenantRestoredToWorkspace, + context: [ + 'reason' => $reason, + 'before_status' => self::TENANT_POSTURE_REMOVED, + 'after_status' => self::TENANT_POSTURE_ACTIVE, + 'previous_removed_from_workspace_at' => $previousRemovedAt?->toISOString(), + 'previous_removed_from_workspace_reason' => $previousReason, + 'previous_removed_from_workspace_by_user_id' => $previousActorId, + ], + actor: $actor, + summary: 'ManagedEnvironment restored to workspace for '.$tenant->name, + ); + + return $tenant; + }); + } + + private function authorizePlatformDirectoryManagement(PlatformUser $actor): void + { + if ($actor->hasCapability(PlatformCapabilities::DIRECTORY_MANAGE)) { + return; + } + + throw new AuthorizationException('You are not allowed to manage workspace lifecycle.'); + } + + private function normalizeReason(string $reason): string + { + $reason = trim($reason); + + if (mb_strlen($reason) < 5) { + throw ValidationException::withMessages([ + 'reason' => 'Provide a reason with at least 5 characters.', + ]); + } + + if (mb_strlen($reason) > 2000) { + throw ValidationException::withMessages([ + 'reason' => 'Provide a reason with 2000 characters or fewer.', + ]); + } + + return $reason; + } +} diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 730d958c..d2c90599 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -15,6 +15,8 @@ enum AuditActionId: string case TenantArchived = 'tenant.archived'; case TenantRestored = 'tenant.restored'; case TenantReturnedToDraft = 'tenant.returned_to_draft'; + case TenantRemovedFromWorkspace = 'tenant.removed_from_workspace'; + case TenantRestoredToWorkspace = 'tenant.restored_to_workspace'; case TenantMembershipAdd = 'tenant_membership.add'; case TenantMembershipRoleChange = 'tenant_membership.role_change'; @@ -62,6 +64,8 @@ enum AuditActionId: string case WorkspaceSettingUpdated = 'workspace_setting.updated'; case WorkspaceSettingReset = 'workspace_setting.reset'; case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated'; + case WorkspaceClosed = 'workspace.closed'; + case WorkspaceReopened = 'workspace.reopened'; case BaselineProfileCreated = 'baseline_profile.created'; case BaselineProfileUpdated = 'baseline_profile.updated'; @@ -193,6 +197,8 @@ private static function labels(): array self::TenantArchived->value => 'ManagedEnvironment archived', self::TenantRestored->value => 'ManagedEnvironment restored', self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft', + self::TenantRemovedFromWorkspace->value => 'ManagedEnvironment removed from workspace', + self::TenantRestoredToWorkspace->value => 'ManagedEnvironment restored to workspace', self::TenantMembershipAdd->value => 'ManagedEnvironment member add', self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role change', self::TenantMembershipRemove->value => 'ManagedEnvironment member removal', @@ -227,6 +233,8 @@ private static function labels(): array self::WorkspaceSettingUpdated->value => 'Workspace setting updated', self::WorkspaceSettingReset->value => 'Workspace setting reset', self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated', + self::WorkspaceClosed->value => 'Workspace closed', + self::WorkspaceReopened->value => 'Workspace reopened', self::BaselineProfileCreated->value => 'Baseline profile created', self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileArchived->value => 'Baseline profile archived', @@ -333,6 +341,8 @@ private static function summaries(): array self::TenantArchived->value => 'ManagedEnvironment archived', self::TenantRestored->value => 'ManagedEnvironment restored', self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft', + self::TenantRemovedFromWorkspace->value => 'ManagedEnvironment removed from workspace', + self::TenantRestoredToWorkspace->value => 'ManagedEnvironment restored to workspace', self::TenantMembershipAdd->value => 'ManagedEnvironment member added', self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role changed', self::TenantMembershipRemove->value => 'ManagedEnvironment member removed', @@ -342,6 +352,8 @@ private static function summaries(): array self::WorkspaceSettingUpdated->value => 'Workspace setting updated', self::WorkspaceSettingReset->value => 'Workspace setting reset', self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated', + self::WorkspaceClosed->value => 'Workspace closed', + self::WorkspaceReopened->value => 'Workspace reopened', self::BaselineProfileCreated->value => 'Baseline profile created', self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileArchived->value => 'Baseline profile archived', diff --git a/apps/platform/app/Support/Auth/PlatformCapabilities.php b/apps/platform/app/Support/Auth/PlatformCapabilities.php index b74acfef..fb114553 100644 --- a/apps/platform/app/Support/Auth/PlatformCapabilities.php +++ b/apps/platform/app/Support/Auth/PlatformCapabilities.php @@ -18,6 +18,8 @@ class PlatformCapabilities public const DIRECTORY_VIEW = 'platform.directory.view'; + public const DIRECTORY_MANAGE = 'platform.directory.manage'; + public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage'; public const SUPPORT_ACCESS_MANAGE = 'platform.support_access.manage'; diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index 3ad46442..f3d081a5 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -40,6 +40,7 @@ final class BadgeCatalog BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class, BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class, BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class, + BadgeDomain::TenantWorkspacePosture->value => Domains\TenantWorkspacePostureBadge::class, BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class, BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class, BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class, @@ -60,6 +61,7 @@ final class BadgeCatalog BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, + BadgeDomain::WorkspaceClosurePosture->value => Domains\WorkspaceClosurePostureBadge::class, BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index 10df6f71..21384373 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -31,6 +31,7 @@ enum BadgeDomain: string case BooleanEnabled = 'boolean_enabled'; case BooleanHasErrors = 'boolean_has_errors'; case TenantStatus = 'tenant_status'; + case TenantWorkspacePosture = 'tenant_workspace_posture'; case TenantRbacStatus = 'tenant_rbac_status'; case TenantPermissionStatus = 'tenant_permission_status'; case PolicySnapshotMode = 'policy_snapshot_mode'; @@ -51,6 +52,7 @@ enum BadgeDomain: string case BaselineProfileStatus = 'baseline_profile_status'; case FindingType = 'finding_type'; case ReviewPackStatus = 'review_pack_status'; + case WorkspaceClosurePosture = 'workspace_closure_posture'; case CommercialLifecycleState = 'commercial_lifecycle_state'; case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceCompleteness = 'evidence_completeness'; diff --git a/apps/platform/app/Support/Badges/Domains/TenantWorkspacePostureBadge.php b/apps/platform/app/Support/Badges/Domains/TenantWorkspacePostureBadge.php new file mode 100644 index 00000000..77701c19 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/TenantWorkspacePostureBadge.php @@ -0,0 +1,22 @@ + new BadgeSpec('In workspace', 'success', 'heroicon-m-check-circle'), + WorkspaceLifecycleService::TENANT_POSTURE_REMOVED => new BadgeSpec('Removed from workspace', 'warning', 'heroicon-m-no-symbol'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Badges/Domains/WorkspaceClosurePostureBadge.php b/apps/platform/app/Support/Badges/Domains/WorkspaceClosurePostureBadge.php new file mode 100644 index 00000000..49305628 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/WorkspaceClosurePostureBadge.php @@ -0,0 +1,22 @@ + new BadgeSpec('Open', 'success', 'heroicon-m-check-circle'), + WorkspaceLifecycleService::WORKSPACE_POSTURE_CLOSED => new BadgeSpec('Closed', 'danger', 'heroicon-m-lock-closed'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php b/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php index 9b959b82..c6ac2037 100644 --- a/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php +++ b/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php @@ -32,6 +32,16 @@ public function handle(Request $request, Closure $next): Response abort(404); } + $path = '/'.ltrim($request->path(), '/'); + + if ($tenant->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) { + abort(404); + } + + if ($tenant->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) { + abort(404); + } + return $next($request); } } diff --git a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php index 9657d9f3..cf062368 100644 --- a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -45,6 +45,12 @@ public function handle(Request $request, Closure $next): Response $user = $request->user(); if ($existingTenant instanceof ManagedEnvironment && $user instanceof User && ! $user->canAccessTenant($existingTenant)) { Filament::setTenant(null, true); + $existingTenant = null; + } + + if ($existingTenant instanceof ManagedEnvironment && ($existingTenant->isRemovedFromWorkspace() || $existingTenant->workspace?->isClosed())) { + Filament::setTenant(null, true); + $existingTenant = null; } if ($this->isLivewireUpdatePath($path)) { @@ -103,6 +109,14 @@ public function handle(Request $request, Closure $next): Response abort(404); } + if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) { + abort(404); + } + + if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) { + abort(404); + } + if ( $resolvedContext->hasTenant() && ( diff --git a/apps/platform/app/Support/Tenants/ReferencedTenantLifecyclePresentation.php b/apps/platform/app/Support/Tenants/ReferencedTenantLifecyclePresentation.php index b2b76965..646a5856 100644 --- a/apps/platform/app/Support/Tenants/ReferencedTenantLifecyclePresentation.php +++ b/apps/platform/app/Support/Tenants/ReferencedTenantLifecyclePresentation.php @@ -13,6 +13,8 @@ public function __construct( public ?int $tenantId, public ?string $tenantName, public TenantLifecyclePresentation $presentation, + public bool $removedFromWorkspace, + public bool $workspaceClosed, public ?string $contextNote, ) {} @@ -30,7 +32,9 @@ public static function fromTenant(ManagedEnvironment $tenant, string $viewerCont tenantId: (int) $tenant->getKey(), tenantName: $tenant->name, presentation: $presentation, - contextNote: self::contextNoteFor($presentation), + removedFromWorkspace: $tenant->isRemovedFromWorkspace(), + workspaceClosed: (bool) $tenant->workspace?->isClosed(), + contextNote: self::contextNoteFor($presentation, $tenant->isRemovedFromWorkspace(), (bool) $tenant->workspace?->isClosed()), ); } @@ -41,12 +45,22 @@ public static function forInvalid(string $viewerContext, ?ManagedEnvironment $te tenantId: $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null, tenantName: $tenant?->name, presentation: TenantLifecyclePresentation::invalid($normalizedValue), + removedFromWorkspace: $tenant?->isRemovedFromWorkspace() ?? false, + workspaceClosed: (bool) $tenant?->workspace?->isClosed(), contextNote: 'Some tenant follow-up actions may be unavailable from this canonical workspace view.', ); } public function selectorAvailabilityMessage(): ?string { + if ($this->removedFromWorkspace) { + return 'This tenant was removed from its workspace and may not appear in the tenant selector.'; + } + + if ($this->workspaceClosed) { + return 'This tenant belongs to a closed workspace and may not appear in active tenant context.'; + } + if ($this->presentation->isInvalidFallback) { return 'This tenant has an invalid lifecycle value and may not appear in the tenant selector.'; } @@ -58,8 +72,12 @@ public function selectorAvailabilityMessage(): ?string return null; } - private static function contextNoteFor(TenantLifecyclePresentation $presentation): ?string + private static function contextNoteFor(TenantLifecyclePresentation $presentation, bool $removedFromWorkspace, bool $workspaceClosed): ?string { + if ($removedFromWorkspace || $workspaceClosed) { + return 'Historical operation context remains available, but active tenant follow-up actions are unavailable from this canonical workspace view.'; + } + if ($presentation->isInvalidFallback || ! $presentation->isSelectableAsContext()) { return 'Some tenant follow-up actions may be unavailable from this canonical workspace view.'; } diff --git a/apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php b/apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php index 39193e14..8208de27 100644 --- a/apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php +++ b/apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php @@ -22,6 +22,8 @@ enum TenantOperabilityReasonCode: string case OnboardingNotResumable = 'onboarding_not_resumable'; case CanonicalViewFollowupOnly = 'canonical_view_followup_only'; case RememberedContextStale = 'remembered_context_stale'; + case WorkspaceClosed = 'workspace_closed'; + case TenantRemovedFromWorkspace = 'tenant_removed_from_workspace'; public function operatorLabel(): string { @@ -36,6 +38,8 @@ public function operatorLabel(): string self::OnboardingNotResumable => 'Onboarding cannot be resumed', self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context', self::RememberedContextStale => 'Saved tenant context is stale', + self::WorkspaceClosed => 'Workspace is closed', + self::TenantRemovedFromWorkspace => 'ManagedEnvironment removed from workspace', }; } @@ -52,6 +56,8 @@ public function shortExplanation(): string self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.', self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.', self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.', + self::WorkspaceClosed => 'This workspace is closed and cannot be used for active tenant context or new tenant operations until it is reopened.', + self::TenantRemovedFromWorkspace => 'This tenant was removed from the workspace and cannot be selected or used for new tenant operations until it is restored.', }; } @@ -59,7 +65,7 @@ public function actionability(): string { return match ($this) { self::TenantAlreadyArchived => 'non_actionable', - self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale => 'prerequisite_missing', + self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale, self::WorkspaceClosed, self::TenantRemovedFromWorkspace => 'prerequisite_missing', default => 'permanent_configuration', }; } @@ -103,6 +109,12 @@ public function nextSteps(): array self::SelectorIneligibleLifecycle, self::RememberedContextStale => [ NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'), ], + self::TenantRemovedFromWorkspace => [ + NextStepOption::instruction('Restore the tenant to the workspace before using it as active context.', scope: 'workspace'), + ], + self::WorkspaceClosed => [ + NextStepOption::instruction('Reopen the workspace before using active tenant context or starting new tenant operations.', scope: 'workspace'), + ], self::TenantNotArchived => [ NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'), ], diff --git a/apps/platform/app/Support/Workspaces/WorkspaceContext.php b/apps/platform/app/Support/Workspaces/WorkspaceContext.php index 10a27986..34cd8f2f 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceContext.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceContext.php @@ -84,6 +84,10 @@ public function currentWorkspaceOrTenantWorkspace(?ManagedEnvironment $tenant = public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void { + if (! $this->isWorkspaceSelectable($workspace)) { + throw new NotFoundHttpException; + } + $session = ($request && $request->hasSession()) ? $request->session() : session(); $session->put(self::SESSION_KEY, (int) $workspace->getKey()); @@ -316,7 +320,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten { $workspace = $this->currentWorkspaceForMemberOrFail($user, $request); - if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || ! $user->canAccessTenant($tenant)) { + if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || $tenant->isRemovedFromWorkspace() || ! $user->canAccessTenant($tenant)) { throw new NotFoundHttpException; } @@ -325,7 +329,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten private function isWorkspaceSelectable(Workspace $workspace): bool { - return empty($workspace->archived_at); + return $workspace->isSelectableAsContext(); } private function userCanAccessTenant(ManagedEnvironment $tenant, ?Request $request = null): bool diff --git a/apps/platform/database/migrations/2026_05_07_000000_add_closure_fields_to_workspaces_table.php b/apps/platform/database/migrations/2026_05_07_000000_add_closure_fields_to_workspaces_table.php new file mode 100644 index 00000000..264adab4 --- /dev/null +++ b/apps/platform/database/migrations/2026_05_07_000000_add_closure_fields_to_workspaces_table.php @@ -0,0 +1,28 @@ +timestamp('closed_at')->nullable()->after('archived_at'); + $table->foreignId('closed_by_platform_user_id')->nullable()->after('closed_at')->constrained('platform_users')->nullOnDelete(); + $table->text('closed_reason')->nullable()->after('closed_by_platform_user_id'); + + $table->index('closed_at'); + }); + } + + public function down(): void + { + Schema::table('workspaces', function (Blueprint $table): void { + $table->dropIndex(['closed_at']); + $table->dropConstrainedForeignId('closed_by_platform_user_id'); + $table->dropColumn(['closed_at', 'closed_reason']); + }); + } +}; diff --git a/apps/platform/database/migrations/2026_05_07_000001_add_workspace_removal_fields_to_managed_environments_table.php b/apps/platform/database/migrations/2026_05_07_000001_add_workspace_removal_fields_to_managed_environments_table.php new file mode 100644 index 00000000..34a3578e --- /dev/null +++ b/apps/platform/database/migrations/2026_05_07_000001_add_workspace_removal_fields_to_managed_environments_table.php @@ -0,0 +1,37 @@ +timestamp('removed_from_workspace_at')->nullable()->after('deleted_at'); + $table->foreignId('removed_from_workspace_by_user_id')->nullable()->after('removed_from_workspace_at')->constrained('users')->nullOnDelete(); + $table->text('removed_from_workspace_reason')->nullable()->after('removed_from_workspace_by_user_id'); + + $table->index('removed_from_workspace_at'); + }); + + if (DB::getDriverName() === 'sqlite') { + DB::statement('DROP INDEX IF EXISTS tenants_current_unique'); + DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON managed_environments (is_current) WHERE is_current = 1 AND deleted_at IS NULL'); + } + } + + public function down(): void + { + Schema::table('managed_environments', function (Blueprint $table): void { + $table->dropIndex(['removed_from_workspace_at']); + $table->dropConstrainedForeignId('removed_from_workspace_by_user_id'); + $table->dropColumn([ + 'removed_from_workspace_at', + 'removed_from_workspace_reason', + ]); + }); + } +}; diff --git a/apps/platform/resources/views/filament/pages/choose-workspace.blade.php b/apps/platform/resources/views/filament/pages/choose-workspace.blade.php index 190a61bb..977b6e5e 100644 --- a/apps/platform/resources/views/filament/pages/choose-workspace.blade.php +++ b/apps/platform/resources/views/filament/pages/choose-workspace.blade.php @@ -47,7 +47,7 @@ class="h-6 w-6 text-gray-400 dark:text-gray-500"

No workspaces available

- You don't have access to any workspace yet. Contact your administrator to get started. + You don't have access to any workspace yet. Closed workspaces stay available for administrative history but cannot be selected as active context.

diff --git a/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php b/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php index ecefa21c..54cfd507 100644 --- a/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php +++ b/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php @@ -5,6 +5,7 @@ $providerConnections = $this->providerConnections(); $permissions = $this->tenantPermissions(); $runs = $this->recentRuns(); + $workspacePostureValue = $tenant->isRemovedFromWorkspace() ? 'removed_from_workspace' : 'active'; @endphp @@ -26,11 +27,26 @@ {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }} + + {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantWorkspacePosture, $workspacePostureValue)->label }} + + @if ($tenant->external_id) External ID: {{ $tenant->external_id }} @endif + @if ($tenant->isRemovedFromWorkspace()) +
+

Removed from workspace

+

{{ $tenant->workspaceRemovalReason() ?? 'No removal reason recorded.' }}

+

Active tenant selection and new tenant operations are blocked. Audit, evidence, and operation history remain visible.

+
+ @endif +
Open in tenant admin diff --git a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php index 3dd479d4..f92a51b5 100644 --- a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php +++ b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php @@ -9,6 +9,8 @@ $runs = $this->recentRuns(); $commercialLifecycle = $this->workspaceCommercialLifecycleSummary(); $commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null); + $workspaceClosurePosture = $workspace->isClosed() ? 'closed' : 'open'; + $workspaceClosureBadge = BadgeCatalog::spec(BadgeDomain::WorkspaceClosurePosture, $workspaceClosurePosture); $commercialSourceDescriptor = ($commercialLifecycle['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed'; $commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : []; $activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null; @@ -38,8 +40,30 @@

Tenants

{{ number_format((int) $workspace->tenants_count) }}

+
+

Lifecycle

+
+ + {{ $workspaceClosureBadge->label }} + +
+

+ @if ($workspace->isClosed()) + Closed {{ $workspace->closed_at?->diffForHumans() ?? '' }}. Active selection and new mutations are blocked. + @else + Eligible for workspace selection and normal tenant operations. + @endif +

+
+ @if ($workspace->isClosed()) +
+

Closed workspace

+

{{ $workspace->closureReason() ?? 'No closure reason recorded.' }}

+
+ @endif +
Open in /admin @@ -272,9 +296,9 @@ class="flex items-center justify-between rounded-lg border border-gray-200 px-4 > {{ $tenant->name }} - {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }} + {{ \App\Support\Badges\BadgeRenderer::spec($tenant->isRemovedFromWorkspace() ? \App\Support\Badges\BadgeDomain::TenantWorkspacePosture : \App\Support\Badges\BadgeDomain::TenantStatus, $tenant->isRemovedFromWorkspace() ? 'removed_from_workspace' : (string) $tenant->status)->label }} @endforeach diff --git a/apps/platform/resources/views/filament/system/pages/ops/view-run.blade.php b/apps/platform/resources/views/filament/system/pages/ops/view-run.blade.php index 721e2eba..3c7031d8 100644 --- a/apps/platform/resources/views/filament/system/pages/ops/view-run.blade.php +++ b/apps/platform/resources/views/filament/system/pages/ops/view-run.blade.php @@ -24,6 +24,14 @@ $integrityNote = \App\Support\RedactionIntegrity::noteForRun($run); $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); $decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run); + $workspaceClosureSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::WorkspaceClosurePosture, + $run->workspace?->isClosed() ? 'closed' : 'open', + ); + $tenantWorkspacePostureSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::TenantWorkspacePosture, + $run->tenant?->isRemovedFromWorkspace() ? 'removed_from_workspace' : 'active', + ); @endphp @@ -72,6 +80,26 @@
+
+
Workspace lifecycle
+
+ + {{ $workspaceClosureSpec->label }} + +
+
+ + @if ($run->tenant) +
+
Tenant workspace posture
+
+ + {{ $tenantWorkspacePostureSpec->label }} + +
+
+ @endif +
Started
diff --git a/apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php b/apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php new file mode 100644 index 00000000..369bd3ab --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php @@ -0,0 +1,70 @@ +create(); + $openWorkspace = Workspace::factory()->create(['name' => 'Open Workspace']); + $closedWorkspace = Workspace::factory()->create([ + 'name' => 'Closed Workspace', + 'closed_at' => now(), + 'closed_reason' => 'No longer active.', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $openWorkspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $closedWorkspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertSuccessful() + ->assertSee('Open Workspace') + ->assertDontSee('Closed Workspace'); +}); + +it('clears closed remembered workspace context and routes to explicit recovery', function (): void { + $user = User::factory()->create(); + $openWorkspace = Workspace::factory()->create(); + $closedWorkspace = Workspace::factory()->create([ + 'closed_at' => now(), + 'closed_reason' => 'The workspace was closed by support.', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $openWorkspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $closedWorkspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $closedWorkspace->getKey()])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $closedWorkspace->getKey()]) + ->get('/admin/_test/workspace-context') + ->assertRedirect('/admin/choose-workspace') + ->assertSessionMissing(WorkspaceContext::SESSION_KEY); + + expect($user->fresh()->last_workspace_id)->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php b/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php new file mode 100644 index 00000000..a7f8354f --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php @@ -0,0 +1,120 @@ +active()->create([ + 'name' => 'Removal ManagedEnvironment', + 'is_current' => true, + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('remove_from_workspace') + ->assertActionExists('remove_from_workspace', fn (Action $action): bool => $action->isConfirmationRequired()) + ->callAction('remove_from_workspace', data: [ + 'removal_reason' => 'Tenant was removed from active workspace operations.', + ]) + ->assertHasNoActionErrors() + ->assertNotified('Tenant removed from workspace'); + + $tenant->refresh(); + + expect($tenant->isRemovedFromWorkspace())->toBeTrue() + ->and($tenant->is_current)->toBeFalse() + ->and($tenant->workspaceRemovalReason())->toBe('Tenant was removed from active workspace operations.') + ->and($tenant->isSelectableAsContext())->toBeFalse() + ->and(DB::table('managed_environment_memberships') + ->where('managed_environment_id', (int) $tenant->getKey()) + ->where('user_id', (int) $user->getKey()) + ->exists())->toBeTrue(); + + $removeAudit = AuditLog::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->where('action', AuditActionId::TenantRemovedFromWorkspace->value) + ->latest('id') + ->first(); + + expect($removeAudit)->not->toBeNull() + ->and($removeAudit?->metadata['reason'] ?? null)->toBe('Tenant was removed from active workspace operations.') + ->and($removeAudit?->metadata['after_status'] ?? null)->toBe('removed_from_workspace'); + + $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant->fresh()); + + expect($decision['is_blocked'])->toBeTrue() + ->and($decision['reason_family'])->toBe(WorkspaceCommercialLifecycleResolver::REASON_FAMILY_TENANT_WORKSPACE_REMOVAL); + + expect(fn () => app(OperationRunService::class)->ensureRun( + tenant: $tenant->fresh(), + type: OperationRunType::InventorySync->value, + inputs: ['scope' => 'tenant_removal_test'], + initiator: $user, + ))->toThrow(InvalidArgumentException::class, 'removed from the workspace'); + + expect(OperationRun::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->exists())->toBeFalse(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin')) + ->assertSuccessful() + ->assertSee('Removed from workspace'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/t/'.$tenant->external_id) + ->assertNotFound(); + + Filament::setTenant(null, true); + + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('restore_to_workspace') + ->assertActionExists('restore_to_workspace', fn (Action $action): bool => $action->isConfirmationRequired()) + ->callAction('restore_to_workspace', data: [ + 'restore_reason' => 'Tenant is approved for active workspace operations again.', + ]) + ->assertHasNoActionErrors() + ->assertNotified('Tenant restored to workspace'); + + $tenant->refresh(); + + expect($tenant->isRemovedFromWorkspace())->toBeFalse() + ->and($tenant->workspaceRemovalReason())->toBeNull() + ->and($tenant->isSelectableAsContext())->toBeTrue() + ->and(AuditLog::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->where('action', AuditActionId::TenantRestoredToWorkspace->value) + ->where('metadata->reason', 'Tenant is approved for active workspace operations again.') + ->exists())->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php b/apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php new file mode 100644 index 00000000..795b6af2 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php @@ -0,0 +1,52 @@ +create([ + 'name' => 'Closed Admin Workspace', + 'closed_at' => now(), + 'closed_reason' => 'Closed after customer offboarding.', + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin')) + ->assertSuccessful() + ->assertSee('Closed') + ->assertSee('Closed after customer offboarding.') + ->assertDontSee('Suspended read-only'); + + expect(WorkspaceResource::canEdit($workspace))->toBeFalse(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + Livewire::actingAs($user) + ->test(ViewWorkspace::class, ['record' => $workspace->getRouteKey()]) + ->assertActionHidden('edit'); +}); diff --git a/apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php b/apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php new file mode 100644 index 00000000..6be0d827 --- /dev/null +++ b/apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php @@ -0,0 +1,145 @@ +create(['name' => 'Closure Workspace']); + $workspaceUser = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $workspaceUser->getKey(), + 'role' => 'owner', + ]); + + $tenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Closure ManagedEnvironment', + 'is_current' => true, + ]); + + ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Second Closure ManagedEnvironment', + 'is_current' => false, + ]); + + $operator = PlatformUser::factory()->create([ + 'name' => 'Platform Directory Operator', + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::DIRECTORY_MANAGE, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionVisible('close_workspace') + ->assertActionExists('close_workspace', fn (Action $action): bool => $action->isConfirmationRequired()) + ->callAction('close_workspace', data: [ + 'reason' => 'Customer requested closure for offboarding.', + ]) + ->assertHasNoActionErrors() + ->assertNotified('Workspace closed') + ->assertActionVisible('reopen_workspace') + ->assertActionHidden('close_workspace'); + + $workspace->refresh(); + $tenant->refresh(); + + expect($workspace->isClosed())->toBeTrue() + ->and($workspace->closed_by_platform_user_id)->toBe((int) $operator->getKey()) + ->and($workspace->closureReason())->toBe('Customer requested closure for offboarding.') + ->and($tenant->is_current)->toBeFalse() + ->and(ManagedEnvironment::query()->where('workspace_id', (int) $workspace->getKey())->where('is_current', true)->exists())->toBeFalse() + ->and(WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $workspaceUser->getKey()) + ->exists())->toBeTrue(); + + $closeAudit = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceClosed->value) + ->latest('id') + ->first(); + + expect($closeAudit)->not->toBeNull() + ->and($closeAudit?->actor_name)->toBe('Platform Directory Operator') + ->and($closeAudit?->metadata['reason'] ?? null)->toBe('Customer requested closure for offboarding.') + ->and($closeAudit?->metadata['after_status'] ?? null)->toBe('closed'); + + expect(fn () => app(OperationRunService::class)->ensureRun( + tenant: $tenant->fresh(), + type: OperationRunType::InventorySync->value, + inputs: ['scope' => 'workspace_closure_test'], + initiator: $workspaceUser, + ))->toThrow(InvalidArgumentException::class, 'Workspace is closed'); + + expect(OperationRun::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->exists())->toBeFalse(); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace->fresh()]) + ->assertActionVisible('reopen_workspace') + ->assertActionExists('reopen_workspace', fn (Action $action): bool => $action->isConfirmationRequired()) + ->callAction('reopen_workspace', data: [ + 'reason' => 'Workspace access is approved again.', + ]) + ->assertHasNoActionErrors() + ->assertNotified('Workspace reopened') + ->assertActionVisible('close_workspace'); + + $workspace->refresh(); + + expect($workspace->isClosed())->toBeFalse() + ->and($workspace->closed_by_platform_user_id)->toBeNull() + ->and($workspace->closureReason())->toBeNull() + ->and(AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceReopened->value) + ->where('metadata->reason', 'Workspace access is approved again.') + ->exists())->toBeTrue(); +}); + +it('hides workspace closure mutations from platform users without directory manage capability', function (): void { + $workspace = Workspace::factory()->create(); + $viewer = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($viewer, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionHidden('close_workspace') + ->assertActionHidden('reopen_workspace'); +}); diff --git a/apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php b/apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php new file mode 100644 index 00000000..3993e4ab --- /dev/null +++ b/apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php @@ -0,0 +1,69 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPERATIONS_VIEW, + ], + 'is_active' => true, + ]); + + $workspace = Workspace::factory()->create([ + 'name' => 'Historical Closure Workspace', + 'closed_at' => now(), + 'closed_by_platform_user_id' => (int) $platformUser->getKey(), + 'closed_reason' => 'Historical closure for support verification.', + ]); + + $tenantUser = User::factory()->create(); + $tenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Removed Historical ManagedEnvironment', + 'removed_from_workspace_at' => now(), + 'removed_from_workspace_by_user_id' => (int) $tenantUser->getKey(), + 'removed_from_workspace_reason' => 'Tenant was offboarded from the workspace.', + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'started_at' => now()->subMinutes(3), + 'completed_at' => now(), + ]); + + $this->actingAs($platformUser, 'platform') + ->get(SystemOperationRunLinks::view($run)) + ->assertSuccessful() + ->assertSee('Historical Closure Workspace') + ->assertSee('Removed Historical ManagedEnvironment') + ->assertSee('Workspace lifecycle') + ->assertSee('Closed') + ->assertSee('Tenant workspace posture') + ->assertSee('Removed from workspace'); +}); diff --git a/specs/292-workspace-tenant-closure/plan.md b/specs/292-workspace-tenant-closure/plan.md new file mode 100644 index 00000000..7f87ed82 --- /dev/null +++ b/specs/292-workspace-tenant-closure/plan.md @@ -0,0 +1,302 @@ +# Implementation Plan: Workspace & Tenant Closure Lifecycle v1 + +**Branch**: `292-workspace-tenant-closure` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/292-workspace-tenant-closure/spec.md` + +## Summary + +Prepare one bounded lifecycle runtime follow-through on top of the Spec 262 taxonomy foundation plus the already-real tenant operability and commercial lifecycle seams. The narrow implementation path is to add explicit workspace closed truth on `Workspace`, explicit removed-from-workspace truth on `ManagedEnvironment`, keep commercial suspension as a separate shared gate, and reuse the existing system directory, admin resource, chooser, and canonical historical viewer surfaces instead of opening a new workflow console. + +This slice stays deliberately narrow. Filament remains v5 on Livewire v4, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no asset registration change is expected. The plan does not reopen the broader taxonomy, does not implement purge or export-before-delete, does not add payment-provider or billing-portal behavior, and does not add a new `OperationRun` family. + +## Inherited Baseline / Explicit Delta + +### Inherited baseline + +- `Workspace` already carries `archived_at`, and `WorkspaceContext::isWorkspaceSelectable()` already uses that archived truth to prevent invalid workspace selection. +- `ManagedEnvironment` already carries tenant lifecycle truth, and `TenantLifecycle` plus `TenantOperabilityService` already distinguish active context from onboarding or archive semantics. +- `WorkspaceCommercialLifecycleResolver` already maps commercial truth into `SUSPENDED_READ_ONLY` versus normal workspace posture for high-impact actions. +- `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, and `DenyNonMemberTenantAccess` already provide the current context-recovery and deny-as-not-found seams. +- `WorkspaceMembershipManager`, `TenantMembershipManager`, `WorkspaceAuditLogger`, and current tenant audit paths already provide bounded audit-safe mutation seams. +- Existing admin and system surfaces already exist at `ViewWorkspace`, `ViewTenant`, `ViewRun`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceResource`, `TenantResource`, and `WorkspaceSettings`. + +### Explicit delta in this plan + +- Add explicit closure truth to `Workspace` using bounded persisted fields for closed posture, actor, and reason. +- Add explicit removed-from-workspace truth to `ManagedEnvironment` using bounded persisted fields for removal posture, actor, and reason. +- Introduce one bounded `WorkspaceLifecycleService` to orchestrate close/reopen and remove/restore without opening a generic lifecycle framework. +- Keep `archived`, `suspended read-only`, `closed`, and `removed from workspace` as four distinct meanings with separate badges, copy, and blocked-action explanations. +- Update chooser recovery, tenant-context legitimacy, and canonical historical viewers so historical records remain readable while invalid active context is cleared explicitly. +- Keep all new lifecycle mutations inside current admin and system surfaces and current audit infrastructure. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, current audit infrastructure, current Filament admin and system pages +**Storage**: PostgreSQL via targeted new lifecycle fields on existing `workspaces` and `managed_environments` tables plus existing audit and history tables +**Testing**: Pest v4 focused `Feature` coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform` across the existing admin and system Filament panels +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: DB-only lifecycle gating, no new queue family, no new Graph calls, no new browser-only proof requirement +**Constraints**: no purge, no export-before-delete, no payment-provider or billing workflow, no new global-search resource, no new panel, no lifecycle engine, no asset-registration change +**Scale/Scope**: 2 existing persisted records gain bounded lifecycle truth, 1 bounded service seam, 6 existing operator surfaces, and focused feature-test extensions + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Models/Workspace.php` +- `apps/platform/app/Models/ManagedEnvironment.php` +- `apps/platform/database/migrations/*_add_workspace_closure_fields.php` +- `apps/platform/database/migrations/*_add_managed_environment_workspace_removal_fields.php` +- `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php` +- `apps/platform/app/Support/Workspaces/WorkspaceContext.php` +- `apps/platform/app/Services/Tenants/TenantOperabilityService.php` +- `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` +- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` +- `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php` +- `apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php` +- `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` +- `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` +- `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` +- `apps/platform/app/Filament/Pages/ChooseWorkspace.php` +- `apps/platform/app/Filament/Pages/ChooseTenant.php` +- `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` +- `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` +- `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` +- `apps/platform/app/Filament/Resources/TenantResource.php` +- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` +- current tenant audit logging path where tenant membership and lifecycle actions are recorded +- focused feature tests under `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Resources/`, and `apps/platform/tests/Feature/Filament/Pages/` + +## Lifecycle Truth Fit + +- Treat workspace closure as a dedicated workspace-owned lifecycle posture, not as archive and not as commercial suspension. +- Treat tenant removal from workspace as a dedicated workspace-owned posture on the tenant record, not as tenant archive and not as provider absence. +- Keep commercial suspension in `WorkspaceCommercialLifecycleResolver` as the shared source of `SUSPENDED_READ_ONLY`; closure logic must compose with it, not replace it. +- Preserve current tenant lifecycle and provider-missing semantics; the feature only adds workspace-governed closure and removal truth. +- Preserve historical readability: + - closed workspace -> not selectable as current workspace, read-only historical inspection remains available to entitled actors + - removed tenant -> not selectable as current tenant, canonical historical inspection remains available to entitled actors + - suspended read-only -> current workspace remains inspectable and current tenant may remain visible, but mutations and starts stay blocked per existing commercial rules + +## Data & Query Fit + +- Extend `workspaces` with bounded closure fields only. The expected shape is a timestamp, actor reference, and reason text rather than a broad lifecycle ledger. +- Extend `managed_environments` with bounded removed-from-workspace fields only. The expected shape mirrors the workspace closure truth and remains reversible. +- Add only the indexes needed to filter active versus closed workspaces and active versus removed tenants. +- Keep lifecycle truth on the primary records instead of introducing a new history table. Audit remains the historical source for who changed posture and when. +- Preserve existing ownership rules: workspaces stay workspace-owned, tenants stay workspace-owned, and no cross-workspace migration or copy is introduced. + +## UI / Filament & Livewire Fit + +- Existing operator-facing surfaces remain native Filament surfaces under Livewire v4; this slice must stay inside those surfaces. +- `ViewWorkspace` in the system plane keeps one dominant action: `Close workspace` or `Reopen workspace`. +- Admin workspace detail gains read-only posture summary only; no second mutation plane for closure appears on `/admin`. +- Managed tenant list keeps row click as the inspect model and moves remove or restore into `More`; managed tenant detail may expose `Remove tenant` or `Restore tenant` as the dominant lifecycle action. +- Chooser surfaces remain chooser surfaces. They gain explicit recovery messaging, not a new workflow. +- Canonical historical viewers gain posture badges or supporting text only; they do not become mutation surfaces. +- Confirmation modals and success or error notifications on the in-scope action surfaces must reuse the canonical verbs `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant`. +- No new globally searchable Filament resource is introduced, so there is no new global-search Edit or View page requirement to satisfy. +- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no new asset strategy is planned. If future shared assets ever become necessary, deployment remains the normal `cd apps/platform && php artisan filament:assets` path. + +## RBAC / Policy Fit + +- Workspace and tenant membership remain the isolation boundaries. +- Wrong-plane actors and non-members stay `404`; in-scope actors missing capability stay `403`. +- Closure and removal actions remain server-side authorized and confirmation-protected. +- Platform closure uses the current platform workspace-governance seam or the narrowest bounded extension of it. Tenant removal uses the current workspace owner or equivalent tenant-governance seam. +- The exact write-side enforcement seam is `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php`, and the in-scope mutation entry points are the system workspace detail actions in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and the tenant lifecycle actions in `apps/platform/app/Filament/Resources/TenantResource.php`. +- Unsafe close or remove attempts must preserve current guard failures with a clear operator-facing reason and no partial lifecycle mutation. +- Closure or removal posture must never substitute for authorization. A historically readable record still requires entitlement. +- Tenant-context routes under `/admin/t/{tenant}/...` must reject removed tenants as active context targets while preserving canonical viewers that are not tenant-context routes. + +## Audit / Logging Fit + +- Every close/reopen and remove/restore mutation must write an audit event with actor, old posture, new posture, timestamp, and reason. +- Existing audit infrastructure must be reused rather than opening a new lifecycle audit subsystem. +- Historical viewers should surface closure or removal context through current summary and linked audit rather than duplicating the full audit payload. +- Blocked active-context starts caused by closure or removal do not need a new audit family in this slice unless existing blocked-action logging already applies. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: status messaging, chooser recovery, detail actions, canonical historical viewers, audit-backed lifecycle copy +- **State layers in scope**: shell, page, detail +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third +- **Raw/support gating plan**: raw audit and low-level identifiers remain secondary and platform-only where applicable +- **One-primary-action / duplicate-truth control**: system workspace detail keeps one dominant closure action; tenant detail keeps one dominant remove/restore action; chooser recovery surfaces keep one next step +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; hard-stop candidate if implementation adds a second closure plane or local lifecycle vocabulary +- **Special surface test profiles**: standard-native-filament, global-context-shell, shared-detail-family +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned; any lifecycle dashboard, broad workbench, or browser-heavy proof demand is out-of-scope drift +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, chooser pages, system directory pages, admin workspace and tenant resources, audit logging, and canonical historical viewers +- **Shared abstractions reused**: current context manager, current commercial lifecycle resolver, current tenant operability service, current audit infrastructure, `BadgeCatalog` and `BadgeRenderer`, current Filament action-surface patterns +- **New abstraction introduced? why?**: one bounded `WorkspaceLifecycleService`, because close/reopen and remove/restore each require coordinated record mutation, audit logging, and shared blocked-action consequences across multiple surfaces +- **Why the existing abstraction was sufficient or insufficient**: current services already decide selectability, commercial blocking, and audit shape, but no existing service owns the explicit closure or removal mutation path or the shared write-side behavior for it +- **Bounded deviation / spread control**: no second closure orchestration helper is allowed outside the bounded service path + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, by reuse and blocking only +- **Central contract reused**: current shared `OperationRun` start UX and current canonical monitoring pages remain authoritative +- **Delegated UX behaviors**: blocked starts in closed workspaces or removed-tenant contexts must fail before enqueue on `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`; existing run links and canonical historical viewers remain unchanged for already-created runs +- **Surface-owned behavior kept local**: close/reopen and remove/restore input collection plus impact summaries only +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: unchanged central lifecycle mechanism +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: none in this slice +- **Platform-core seams**: workspace closure truth, tenant removal truth, chooser recovery, and historical record readability +- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `closed`, `removed from workspace`, `suspended read-only`, `history` +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: provider-missing and other provider lifecycle work stays in separate follow-up specs + +## Constitution Check + +*GATE: Must pass before implementation begins and again before merge.* + +- Inventory-first / snapshot truth: PASS. No inventory or snapshot source is reinterpreted. +- Read/write separation: PASS. The only new writes are explicit lifecycle mutations with confirmation, audit, and test requirements. +- Graph contract path: PASS. No Graph calls are introduced. +- Deterministic capabilities: PASS. Existing capability registries remain authoritative. +- Workspace and tenant isolation: PASS. Existing 404 and 403 rules remain and are reinforced. +- RBAC-UX plane separation: PASS. System owns workspace closure mutation; admin remains read-only for workspace closure and bounded for tenant removal inside the current workspace. +- Destructive action discipline: PASS. Close/reopen and remove/restore are destructive-like and must remain confirmation-protected. +- Global search safety: PASS. No new searchable resource is introduced. +- OperationRun / Ops-UX: PASS by reuse only. No new run family is added, and blocked starts stay pre-enqueue. +- Data minimization: PASS. Only bounded lifecycle truth fields are added on existing records. +- Test governance: PASS. Focused feature lanes are the narrowest honest proof. +- Proportionality / no premature abstraction: PASS. One bounded service is the narrowest write-side seam; no generic lifecycle framework is planned. +- Persisted truth: PASS. Closure and removal posture are real product truth with independent lifecycle and audit need. +- Behavioral state: PASS. Closed and removed-from-workspace change chooser behavior, mutation legality, and historical-view posture. +- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing chooser, audit, badge, and Filament surfaces remain the shared path. +- Provider boundary: PASS. No provider-specific semantics are added. +- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, and no asset registration change is planned. + +**Gate evaluation**: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for system workspace governance, admin read-only posture, managed-tenant remove/restore, chooser recovery, and canonical historical viewer legitimacy +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the slice is about integrated route, page, capability, chooser, and audit behavior; unit-only proof would miss the real lifecycle consequences +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; setup needs explicit workspace and tenant membership plus lifecycle posture states, but no new provider, browser, or heavy defaults +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none planned +- **Surface-class relief / special coverage rule**: standard-native-filament relief for the system and admin detail surfaces; global-context-shell coverage for chooser recovery; shared-detail-family coverage for historical viewers +- **Closing validation and reviewer handoff**: reviewers should rely on the exact commands above, verify that no second mutation plane or lifecycle vocabulary appeared, and confirm that blocked starts stay pre-enqueue while historical viewers stay readable +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local feature-test increase +- **Review-stop questions**: did the slice collapse closure into archive or suspension, did it break canonical history viewers, did it add a second mutation plane, and did it widen into purge/export/billing scope +- **Escalation path**: `document-in-feature` for contained naming or surface drift; `reject-or-split` if the slice widens into broader offboarding, purge, or billing workflows +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the remaining adjacent work is already known and separately named; this slice is the bounded workspace and tenant closure follow-through itself +- **Test-governance outcome**: keep + +## Rollout Considerations + +- Land persistence and the bounded service seam first, then workspace closure surfaces plus chooser recovery, then tenant removal surfaces plus canonical historical viewer polish. +- Keep admin workspace detail read-only for closure mutation throughout the slice. +- Keep commercial suspension readable and separate from closure throughout the rollout. +- Keep no asset changes, no provider registration changes, no new panel work, and no global-search work. + +## Risk Controls + +- Reject any implementation that reuses `archived_at` or commercial suspension as the new closure truth. +- Reject any implementation that deletes memberships, tenants, workspaces, or historical records as part of close/reopen or remove/restore. +- Reject any implementation that makes `/admin` a second workspace-closure mutation plane. +- Reject any implementation that turns the slice into export, purge, payment-provider, or portal work. +- Reject browser-heavy proof as the default validation lane. + +## Project Structure + +### Documentation (this feature) + +```text +specs/292-workspace-tenant-closure/ +├── plan.md +├── spec.md +└── tasks.md +``` + +### Source Code (expected implementation surfaces) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── ChooseTenant.php +│ │ │ ├── ChooseWorkspace.php +│ │ │ └── Settings/ +│ │ │ └── WorkspaceSettings.php +│ │ ├── Resources/ +│ │ │ ├── TenantResource.php +│ │ │ └── Workspaces/ +│ │ │ ├── WorkspaceResource.php +│ │ │ └── Pages/ +│ │ │ └── ViewWorkspace.php +│ │ └── System/Pages/ +│ │ ├── Directory/ +│ │ │ ├── ViewTenant.php +│ │ │ └── ViewWorkspace.php +│ │ └── Ops/ +│ │ └── ViewRun.php +│ ├── Http/Middleware/ +│ │ └── EnsureWorkspaceSelected.php +│ ├── Models/ +│ │ ├── ManagedEnvironment.php +│ │ └── Workspace.php +│ ├── Services/ +│ │ ├── Audit/ +│ │ │ └── WorkspaceAuditLogger.php +│ │ ├── Entitlements/ +│ │ │ └── WorkspaceCommercialLifecycleResolver.php +│ │ ├── Tenants/ +│ │ │ └── TenantOperabilityService.php +│ │ └── Workspaces/ +│ │ └── WorkspaceLifecycleService.php +│ └── Support/ +│ ├── Middleware/ +│ │ ├── DenyNonMemberTenantAccess.php +│ │ └── EnsureFilamentTenantSelected.php +│ └── Workspaces/ +│ └── WorkspaceContext.php +├── database/ +│ └── migrations/ +└── tests/ + └── Feature/ + ├── Filament/ + └── System/ +``` + +**Structure Decision**: Keep the slice inside the existing Laravel monolith and current Filament admin plus system surfaces. Add only targeted lifecycle fields, one bounded service, and focused feature tests. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Bounded lifecycle write service | Close/reopen and remove/restore need coordinated mutation, audit, and shared blocked-action behavior | Controller or page-local closures would duplicate audit and lifecycle consequences across multiple surfaces | + +## Proportionality Review + +- **Current operator problem**: Operators cannot deliberately close a workspace or remove a tenant from a workspace while keeping history readable and posture explicit. +- **Existing structure is insufficient because**: archive and commercial suspension represent different meanings and cannot safely absorb closure or removal truth. +- **Narrowest correct implementation**: add bounded fields on existing records, route writes through one bounded service, and reuse current chooser, audit, and Filament surfaces. +- **Ownership cost created**: migrations, one service, shared status and chooser updates, and feature tests. +- **Alternative intentionally rejected**: generic lifecycle engine, new closure history tables, or overloading archive and suspension. +- **Release truth**: current-release truth. \ No newline at end of file diff --git a/specs/292-workspace-tenant-closure/spec.md b/specs/292-workspace-tenant-closure/spec.md new file mode 100644 index 00000000..75588bac --- /dev/null +++ b/specs/292-workspace-tenant-closure/spec.md @@ -0,0 +1,370 @@ +# Feature Specification: Workspace & Tenant Closure Lifecycle v1 + +**Feature Branch**: `292-workspace-tenant-closure` +**Created**: 2026-05-07 +**Status**: Approved for implementation +**Input**: User description: "Promote the lifecycle taxonomy follow-through into one bounded runtime slice that closes workspaces, removes tenants from workspaces, and defines explicit suspended read-only versus closed behavior without introducing purge flows." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has partial lifecycle truth for tenant operability, archived workspaces, and subscription-driven suspended read-only posture, but it still has no explicit product truth for deliberately closing a workspace or removing a tenant from a workspace while preserving history. +- **Today's failure**: Operators can archive records or lose access implicitly through membership or context drift, but they cannot make an explicit, auditable closure decision. `archived`, `suspended read-only`, selector invalidation, and historical access are still separate behaviors rather than one bounded closure contract. +- **User-visible improvement**: Platform and workspace operators can close or reopen workspaces and remove or restore tenants with explicit confirmation, clear read-only behavior, consistent chooser recovery, and preserved historical viewers instead of relying on implicit 404s or overloaded archive semantics. +- **Smallest enterprise-capable version**: Add explicit closure truth on `Workspace`, explicit removed-from-workspace truth on `ManagedEnvironment`, reuse existing admin and system Filament surfaces, keep history readable, block new mutations and starts where required, and stop short of export, purge, or billing-provider workflows. +- **Explicit non-goals**: No hard delete, no purge engine, no export-before-delete flow, no retention executor, no customer self-serve billing portal, no payment-provider integration, no membership auto-deletion, no new panel, no new global-search resource, no broad lifecycle engine, and no reopening of the full Spec 262 taxonomy package. +- **Permanent complexity imported**: One bounded closure/removal truth on existing records, one bounded write orchestration path, explicit close/reopen and remove/restore action surfaces, chooser and context recovery rules, and focused audit plus feature-test coverage. No new standalone closure table or workflow engine is introduced. +- **Why now**: Spec 262 deliberately reserved `Workspace & Tenant Closure Lifecycle v1` as the next runtime follow-through after the taxonomy-first package, and the repo now has enough real substrate in tenant operability, commercial lifecycle, audit, and workspace context handling to implement it safely. +- **Why not local**: Closure and removal semantics affect workspace selection, tenant selection, tenant-bound routes, canonical run viewers, action gating, audit, and platform/admin surfaces together. A local page fix would preserve ambiguity and drift. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New lifecycle truth, cross-plane UI impact, and destructive-like action semantics. Defense: the slice stays explicitly bounded, reuses existing surfaces and audit paths, adds no purge or export behavior, and resists a generic lifecycle framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + tenant-bound + canonical-view +- **Primary Routes**: + - `/system/directory/workspaces` + - `/system/directory/workspaces/{workspace}` + - `/system/directory/tenants/{tenant}` + - `/system/ops/runs/{run}` + - `/admin/workspaces` + - `/admin/workspaces/{record}` + - `/admin/choose-workspace` + - `/admin/choose-tenant` + - `/admin/settings/workspace` + - existing tenant-management resource pages backed by `apps/platform/app/Filament/Resources/TenantResource.php` + - existing tenant-context routes under `/admin/t/{tenant}/...` +- **Data Ownership**: + - Workspace closure truth remains workspace-owned and lives on the existing `Workspace` record. + - Tenant removal truth remains workspace-owned on the existing `ManagedEnvironment` record and does not create cross-workspace sharing. + - Workspace memberships, tenant memberships, audit logs, evidence, review artifacts, and `OperationRun` history remain owned by their current records and are preserved in place. + - This feature does not introduce new historical ledgers, export bundles, or purge artifacts. +- **RBAC**: + - Authorization planes involved: platform `/system` for workspace close and reopen; admin `/admin` for workspace read-only visibility, chooser recovery, and tenant remove or restore within the current workspace. + - Non-members or actors outside the relevant workspace or tenant scope receive deny-as-not-found (`404`). + - Actors who are in scope but lack the required capability receive forbidden (`403`). + - Canonical record viewers continue to authorize off record ownership and entitlement, not off remembered workspace or tenant context. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Closed workspaces and removed tenants must never become the remembered active context. Canonical viewers may still render historically linked records when entitlement allows, even if the current remembered context was cleared. +- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical viewers must validate workspace ownership of the record, referenced-tenant ownership where applicable, active actor entitlement to the workspace and referenced tenant, and route legitimacy independent of remembered selector state. Closed or removed lifecycle flags are product posture, not authorization shortcuts. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: status messaging, badges, header actions, row or detail actions, chooser recovery messaging, canonical detail viewers, and audit-backed lifecycle copy +- **Systems touched**: `WorkspaceContext`, chooser pages, tenant operability, commercial lifecycle summaries, admin and system detail pages, audit logging, and canonical run or tenant viewers +- **Existing pattern(s) to extend**: `WorkspaceCommercialLifecycleResolver`, `TenantOperabilityService`, `BadgeCatalog` and `BadgeRenderer`, current audit-log infrastructure, current Filament action surfaces, and existing chooser or context-recovery flows +- **Shared contract / presenter / builder / renderer to reuse**: `WorkspaceContext`, `TenantOperabilityService`, `BadgeCatalog` and `BadgeRenderer`, `WorkspaceAuditLogger`, tenant audit logging, current Filament detail and table action patterns, and the current system-directory plus admin resource surfaces +- **Why the existing shared path is sufficient or insufficient**: The repo already has the shared chooser, audit, and status paths needed for bounded runtime delivery. What is missing is the explicit closure and removed-from-workspace truth those shared paths must consume. +- **Allowed deviation and why**: none. This feature must not create a parallel lifecycle language or a page-local closure vocabulary. +- **Consistency impact**: `Suspended read-only`, `Closed`, `Removed from workspace`, `Archived`, and `Provider missing` must remain distinct meanings with consistent badges, copy, and blocked-action explanations. +- **Review focus**: Reviewers must verify that workspace closure and tenant removal reuse the existing shared status, chooser, audit, and canonical viewer seams instead of introducing one-off page logic. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, by lifecycle gating and canonical historical viewing only +- **Shared OperationRun UX contract/layer reused**: existing shared `OperationRun` start UX and current canonical monitoring surfaces remain authoritative +- **Delegated start/completion UX behaviors**: blocked starts in closed workspaces or removed-tenant contexts must happen before enqueue, create no new run, and keep current `View run` or canonical-detail links unchanged for already-existing history +- **Local surface-owned behavior that remains**: system and admin surfaces own only the close/reopen and remove/restore inputs plus the impact summary shown before confirmation +- **Queued DB-notification policy**: unchanged; this feature introduces no new queued notification family +- **Terminal notification path**: unchanged; no new `OperationRun` lifecycle is introduced +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider or platform boundary is broadened here. Workspace closure and tenant removal are workspace-owned lifecycle concerns and must not be modeled as provider state. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| System workspace detail close or reopen controls | yes | Native Filament | header actions, badges, audit-backed detail summaries | page, detail | no | n/a | +| Admin workspace detail read-only closure summary | yes | Native Filament | status messaging, summary sections | page, detail | no | n/a | +| Managed tenant list and detail remove or restore controls | yes | Native Filament | row/detail actions, badges, lifecycle explanations | page, detail | no | n/a | +| Workspace and tenant chooser recovery after closure or removal | yes | Native Filament + global context shell | navigation, shell recovery, selector messaging | shell, page | no | special shell handling only | +| Canonical run and tenant viewers for removed or closed history | yes | Native Filament | shared detail family, historical evidence visibility | detail | no | no new mutation surface | + +## 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 | +|---|---|---|---|---|---|---|---| +| System workspace detail | Primary Decision Surface | Decide whether a workspace should be closed or reopened | Current posture, impact summary, closure reason, one dominant action | Audit history, affected tenant counts, related historical records | Primary because platform users make the closure decision here | Follows workspace governance, not storage internals | Removes guesswork about what closure changes and what remains readable | +| Managed tenant list and detail | Primary Decision Surface | Decide whether a tenant remains in the active workspace set | Tenant posture, removal impact, one dominant remove or restore action | Related memberships, historical runs, audit trail | Primary because workspace operators govern tenant presence here | Aligns to tenant-management workflow | Avoids overloading archive and implicit chooser disappearance | +| Admin workspace detail | Secondary Context Surface | Understand why the workspace is read-only or unavailable for active selection | Workspace posture, read-only explanation, next allowed inspection path | Closure reason, timestamps, linked history | Not primary because the admin plane does not own closure mutation | Supports post-decision inspection inside the workspace family | Keeps one clear explanation instead of repeating blockers across pages | +| Workspace and tenant chooser recovery | Secondary Context Surface | Recover from a cleared or invalid remembered context | Why the prior context is invalid and one next step | Optional timestamps or posture detail only if needed | Not primary because the user is recovering from an already-made decision | Follows operator recovery flow | Replaces silent redirects with one clear explanation | +| Canonical run and historical viewers | Tertiary Evidence / Diagnostics Surface | Inspect historical evidence after closure or removal | Historical record remains readable plus lifecycle badge on related workspace or tenant | Linked audit trail, tenant or workspace metadata | Not primary because the feature does not ask operators to decide here | Keeps evidence and history accessible without reopening active context | Prevents false not-found errors when context changed | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| System workspace detail | support-platform | Current posture, closure reason summary, impact, close or reopen action | Related tenant counts, membership posture, recent activity | Full audit events and internal IDs | `Close workspace` or `Reopen workspace` | Raw audit payloads and low-level IDs remain secondary | One top summary states the posture once; lower sections add evidence only | +| Managed tenant list and detail | operator-MSP, support-platform | Tenant posture, removal explanation, remove or restore action | Related memberships, lifecycle timestamps, affected history counts | Raw audit details and internal identifiers | `Remove tenant` or `Restore tenant` | Raw audit details stay secondary or support-only | The tenant posture chip and summary line are the single visible source of truth | +| Admin workspace detail | operator-MSP | Workspace posture and what remains readable | Commercial-state detail, closure timestamps, guidance to history surfaces | Platform-only internal detail stays hidden | `Review workspace history` | Platform-only internal detail stays hidden | Read-only explanation appears once in the summary region | +| Workspace and tenant chooser recovery | operator-MSP | Why the context was cleared and which chooser or page to use next | Minimal recovery detail only | None | `Choose workspace` or `Choose tenant` | Internal route or entitlement diagnostics stay hidden | Recovery page states the blocker once and points to one next action | +| Canonical run and historical viewers | operator-MSP, support-platform | Historical record remains readable and the related workspace or tenant posture | Linked workspace and tenant metadata, timestamps, linked audit | Raw identifiers and platform-only debug metadata | `Review history` | Raw metadata remains secondary | The record summary owns the lifecycle note so deeper sections do not restate it | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| System workspace detail | Detail / Header Actions | Governance detail | Close or reopen workspace | Route-record detail | n/a | Secondary detail links stay inside sections | Detail header only with confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Workspace identity, posture badge | Workspace | Open, suspended read-only, or closed posture plus impact | none | +| Admin workspace detail | Detail / Context Summary | Workspace management detail | Review closure posture and history | Route-record detail | n/a | Related links inside sections | none on admin plane for closure | `/admin/workspaces` | `/admin/workspaces/{record}` | Workspace identity, posture badge | Workspace | Read-only explanation and next allowed path | none | +| Managed tenant list | List / Table / More | Registry list | Inspect tenant and decide remove or restore | Full-row click | required | `More` for remove or restore and rare actions | `More` only | `/admin/tenants` | `/admin/tenants/{tenant}` | Workspace context, tenant posture | Tenant | Active versus removed posture | none | +| Managed tenant detail | Detail / Header Actions | Governance detail | Remove or restore tenant | Route-record detail | n/a | Secondary lifecycle links inside sections | Detail header or `More` with confirmation | `/admin/tenants` | `/admin/tenants/{tenant}` | Workspace identity, tenant posture | Tenant | Removed-from-workspace state and impact | none | +| Workspace and tenant chooser recovery | Shell / Recovery | Global-context shell | Recover to a valid current context | Single recovery card / chooser list | chooser row click only | None beyond recovery guidance | none | `/admin/choose-workspace` or `/admin/choose-tenant` | same as collection | Current workspace and tenant context validity | Workspace / Tenant | Why the previous context is invalid | global-context-shell | +| Canonical run and historical viewers | Detail / Evidence | Historical detail | Inspect history without reopening active context | Route-record detail | n/a | Secondary related links inside sections | none | `/system/ops/runs` or current historical collection | `/system/ops/runs/{run}` and related detail routes | Workspace or tenant posture badges | Run / Historical record | Historical legitimacy plus related lifecycle note | shared-detail-family | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System workspace detail | Platform operator | Decide whether workspace should be closed or reopened | Governance detail | Should this workspace remain operable? | Workspace posture, closure reason, impact, what remains readable | Full audit trail, internal IDs, related history counts | commercial posture, closure posture | TenantPilot only | Close workspace, Reopen workspace | Close workspace | +| Admin workspace detail | Workspace owner or manager | Understand why current workspace is read-only or unavailable for active context | Workspace management detail | What does this posture change for my workspace work? | Workspace posture, read-only explanation, history access path | Timestamps and related summary counts | commercial posture, closure posture | None on this surface | Review workspace history | None | +| Managed tenant list and detail | Workspace owner | Decide whether tenant remains part of the workspace's active operating set | Governance list/detail | Should this tenant stay operable in this workspace? | Tenant posture, removal explanation, effect on selectors and actions | Membership and historical record details | tenant lifecycle, removed-from-workspace posture | TenantPilot only | Remove tenant, Restore tenant | Remove tenant | +| Workspace and tenant chooser recovery | Workspace member | Recover from invalid remembered context | Global-context shell | Which valid workspace or tenant should I use now? | Recovery explanation and one next chooser action | Minimal recovery detail only | context validity, closure or removal posture | None | Choose workspace, Choose tenant | None | +| Canonical run and historical viewers | Workspace operator or platform operator | Inspect history after closure or removal | Historical detail | Is this record still legitimate and what lifecycle posture does it reflect? | Record summary plus related workspace or tenant posture | Audit links and related metadata | record status, workspace or tenant posture | None | Review history | None | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - explicit workspace closed truth and explicit tenant removed-from-workspace truth on existing records +- **New persisted entity/table/artifact?**: yes - new persisted lifecycle fields on `Workspace` and `ManagedEnvironment`; no new standalone entity or table family beyond targeted columns +- **New abstraction?**: yes, one bounded write-orchestration service if needed for close/reopen and remove/restore audit-safe mutations; no generic lifecycle framework +- **New enum/state/reason family?**: yes - explicit closed and removed-from-workspace posture with audit reason text and derived blocked-action consequences +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: the product cannot currently make a deliberate closure or removal decision without misusing archive semantics, implicit 404s, or commercial suspension language. +- **Existing structure is insufficient because**: `archived_at` on workspaces and the subscription resolver's suspended-read-only posture do not encode an explicit closure decision, and current tenant lifecycle does not cover workspace-specific removal while preserving history. +- **Narrowest correct implementation**: add bounded fields on existing records, derive read-only and selector behavior from those fields plus current commercial posture, reuse current surfaces, and keep mutations inside one bounded orchestration seam. +- **Ownership cost**: migration work, model and middleware touch points, Filament surface updates, audit-log additions, and focused feature-test maintenance. +- **Alternative intentionally rejected**: overloading archive or suspended-read-only semantics was rejected because it would preserve ambiguity; a generic lifecycle engine or new closure table was rejected because current-release truth only needs bounded fields and shared-surface reuse. +- **Release truth**: current-release truth + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, migration shims, historical alias states, and compatibility-specific tests are out of scope unless implementation reveals a concrete current-release blocker. + +Canonical replacement of ambiguous archive or suspension meanings is preferred over preserving overloaded semantics. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: The slice changes Filament surfaces, middleware or context recovery, audit-backed mutations, and canonical viewer legitimacy. Focused feature coverage is the narrowest honest proof because route, policy, and page behavior matter more than isolated unit indirection. +- **New or expanded test families**: focused feature coverage for system directory, admin workspace and tenant resources, chooser recovery, and canonical run or historical viewers +- **Fixture / helper cost impact**: moderate but bounded; test setup needs explicit workspace membership, tenant membership, commercial posture, and closure or removal state, but no new heavy provider or browser defaults +- **Heavy-family visibility / justification**: none; browser coverage is not required for the initial proof path +- **Special surface test profile**: standard-native-filament, global-context-shell, shared-detail-family +- **Standard-native relief or required special coverage**: functional-core plus state-contract coverage is required; browser or heavy-governance coverage is out of scope unless implementation proves a shell-only gap that feature tests cannot honestly prove +- **Reviewer handoff**: reviewers must verify that close/reopen and remove/restore remain confirmation-protected and audit-backed, that chooser recovery clears invalid context explicitly, that blocked start or mutate paths do not create runs, and that canonical historical viewers stay accessible when entitlement allows +- **Budget / baseline / trend impact**: none expected beyond one feature-local increase in admin and system feature coverage +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Close a workspace without losing history (Priority: P1) + +As a platform operator, I need to close and later reopen a workspace explicitly, so that I can stop active operations and mutations while preserving readable history and audit evidence. + +**Why this priority**: Workspace closure is the core enterprise-trust behavior missing from the current product. Without it, every related lifecycle action still depends on implicit archive or access loss. + +**Independent Test**: Can be fully tested by closing a workspace from the system directory, confirming chooser and action gating update, verifying historical viewers remain readable for entitled actors, and reopening the workspace to restore normal operability. + +**Acceptance Scenarios**: + +1. **Given** a platform actor is authorized to govern a workspace, **When** the actor closes the workspace with confirmation and a reason, **Then** the workspace becomes non-selectable as current context, in-scope active mutations are blocked, and the closure decision is written to audit. +2. **Given** a workspace is closed, **When** an entitled actor opens a historical run or workspace detail viewer, **Then** the record remains readable with explicit closed posture and no false not-found error. +3. **Given** a workspace is closed, **When** the platform actor reopens it, **Then** the workspace becomes selectable again and existing memberships plus historical records remain intact. + +--- + +### User Story 2 - Remove a tenant from a workspace without deleting tenant history (Priority: P1) + +As a workspace owner, I need to remove and later restore a tenant from the active workspace set, so that the tenant stops appearing as an operable context without destroying its history, memberships, or canonical records. + +**Why this priority**: Workspace operators need a bounded lifecycle action smaller than deletion and more explicit than archive or hidden selector disappearance. + +**Independent Test**: Can be fully tested by removing a tenant from the tenant-management surface, confirming chooser and tenant-context routes stop treating it as operable, verifying canonical historical viewers still render, and restoring the tenant. + +**Acceptance Scenarios**: + +1. **Given** an owner is authorized for tenant governance, **When** the owner removes a tenant from the workspace with confirmation and a reason, **Then** the tenant no longer appears in choose-tenant or tenant-context start flows and the action is written to audit. +2. **Given** a tenant was removed from the workspace, **When** an entitled actor opens a canonical run or historical detail page that references that tenant, **Then** the record remains readable and the tenant is labeled removed from workspace. +3. **Given** a tenant is removed from the workspace, **When** the owner restores it, **Then** the tenant becomes selectable and operable again without recreating memberships or historical records. + +--- + +### User Story 3 - Distinguish suspended read-only, closed, and removed clearly (Priority: P2) + +As an operator, I need workspace and tenant posture to explain whether I am blocked because the workspace is commercially suspended, explicitly closed, or the tenant was removed from the workspace, so that I know what next action is legitimate. + +**Why this priority**: Clear posture language prevents operators from treating commercial billing state, workspace closure, and tenant removal as the same event. + +**Independent Test**: Can be fully tested by rendering admin and system surfaces plus chooser recovery for a suspended-read-only workspace, a closed workspace, and a removed tenant and confirming each posture has distinct copy, badge semantics, and blocked-action behavior. + +**Acceptance Scenarios**: + +1. **Given** a workspace is commercially suspended but not closed, **When** the actor opens admin or system summaries, **Then** the posture reads suspended read-only and not closed. +2. **Given** a workspace is closed, **When** the actor lands on chooser or current-context recovery surfaces, **Then** the UI explains that the prior workspace is closed and offers one valid next action instead of a silent redirect. +3. **Given** a tenant is removed from the workspace, **When** the actor visits tenant management or historical detail surfaces, **Then** the tenant is labeled removed from workspace and not archived or provider missing. + +### Edge Cases + +- A user lands on `/admin` with a remembered current workspace that was closed after the last session; the product must clear the invalid context and route through an explicit recovery path. +- A remembered current tenant was removed from the workspace while the workspace itself remains open; the tenant context must be cleared without breaking workspace-scoped pages. +- A workspace is already `SUSPENDED_READ_ONLY` from the commercial resolver and then becomes explicitly closed; the product must show both truths without collapsing them into one ambiguous blocker. +- A removed tenant or closed workspace is still referenced by `OperationRun`, audit, evidence, or review artifacts; canonical viewers must stay readable when entitlement allows. +- A workspace close or tenant removal action is attempted while a last-owner or active-membership guard would otherwise make the result unsafe; the feature must preserve current owner-guard behavior and surface a clear failure reason. +- A reopen or restore action is performed after the remembered context was cleared; the product must not silently reactivate old context without the chooser or explicit selection flow confirming it. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls. It does introduce destructive-like lifecycle mutations, so close/reopen and remove/restore must be previewed, confirmation-protected, audit-logged, authorization-checked server-side, and covered by focused tests. No purge or hard-delete behavior is allowed in this slice. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds bounded persisted lifecycle truth on existing records because current release truth now requires a deliberate closure posture distinct from archive and commercial suspension. A generic lifecycle engine, registry, or new closure history ledger is explicitly out of scope. The narrowest correct implementation is to add fields on existing records, derive blocking behavior from those fields plus current commercial posture, and keep write orchestration bounded. + +**Constitution alignment (XCUT-001):** The feature reuses existing chooser, context, badge, audit, and canonical-viewer seams. It must not introduce page-local status vocabularies or a parallel action language for closure and removal. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** System workspace detail and managed tenant detail are the primary decision surfaces. Chooser recovery and historical viewers remain secondary or tertiary surfaces. Default-visible content must explain posture and one next action; deeper audit and raw identifiers stay secondary. + +**Constitution alignment (PROV-001):** This slice is platform-core lifecycle work. Provider presence remains a separate lifecycle dimension from Spec 261 and must not be reused to model closure or removal. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage for native Filament surfaces, chooser recovery, and canonical viewers. No heavy-governance or browser lane should be introduced unless implementation reveals a shell-only gap that feature tests cannot honestly prove. + +**Constitution alignment (OPS-UX):** The feature does not add a new `OperationRun` type. It does require that blocked starts in closed workspaces or removed-tenant contexts fail before enqueue and that existing canonical run viewers remain authoritative and readable when entitlement allows. + +**Constitution alignment (OPS-UX-START-001):** Existing start surfaces continue to rely on the shared `OperationRun` start UX path when allowed. Close or remove posture only changes whether a start is permitted; it must not introduce local queued toast, dedupe, or notification semantics. + +**Constitution alignment (RBAC-UX):** Platform and admin planes remain separated. Workspace non-members, tenant non-members, and wrong-plane actors get `404`; in-scope actors lacking capability get `403`. Destructive-like actions must use `->requiresConfirmation()` and server-side authorization. Closed or removed posture is never a substitute for an authorization decision. + +**Constitution alignment (OPS-EX-AUTH-001):** This feature does not change auth-handshake behavior. + +**Constitution alignment (BADGE-001):** Workspace and tenant posture badges must be centralized. `Suspended read-only`, `Closed`, and `Removed from workspace` must not fall back to ad hoc labels or local color choices. + +**Constitution alignment (UI-FIL-001):** All changed admin and system surfaces remain native Filament pages or resources. No local card system, no ad hoc status-color system, and no fake row interactivity may be introduced. One dominant action per primary decision surface is required. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing verbs are `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant`. `Workspace` and `Tenant` are the real target objects and must remain stable across buttons, modals, audit prose, and notifications. + +**Constitution alignment (DECIDE-001):** This feature adds explicit human-in-the-loop lifecycle decisions. The default experience must become calmer and clearer by separating active context, read-only closure posture, and historical evidence rather than adding more ambiguous lifecycle states. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Changed surfaces must preserve exactly one primary inspect/open model, keep destructive actions in header or `More`, avoid redundant view actions, and keep chooser recovery and historical viewers focused on one immediate question. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Workspace close/reopen and tenant remove/restore must not compete with navigation. Primary decision surfaces keep one dominant action; rare or adjacent lifecycle actions stay grouped. + +**Constitution alignment (OPSURF-001):** Every changed surface must show whether the blocker is commercial suspension, explicit closure, or tenant removal. Mutation scope must stay legible: all lifecycle mutations in this slice are TenantPilot-only and do not mutate Microsoft tenants. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must map existing domain truth directly to UI posture without adding a new presentation taxonomy or semantic wrapper layer. Tests should prove user-facing business consequences and access behavior, not thin indirection. + +**Constitution alignment (Filament Action Surfaces):** System workspace detail, admin workspace detail, managed tenant list and detail, chooser recovery, and any touched historical viewers must satisfy the action-surface contract described in the matrix below. + +**Constitution alignment (UX-001 - Layout & Information Architecture):** Existing Filament layouts remain in force. Any added summary or warning content must fit current Main/Aside or detail-section layouts, keep one primary action, and preserve native empty-state and badge semantics. + +### Functional Requirements + +- **FR-001**: The system MUST model workspace closure as explicit product truth distinct from `archived_at` and distinct from commercial `SUSPENDED_READ_ONLY` posture. +- **FR-002**: Authorized platform actors MUST be able to close and reopen a workspace through an explicit confirmation flow that captures a human-readable reason and writes an audit event. +- **FR-003**: A closed workspace MUST stop being selectable as the current workspace in `/admin/choose-workspace` and MUST clear any remembered tenant context tied to that workspace. +- **FR-004**: A closed workspace MUST block the in-scope tenant start surface and the in-scope state-changing admin surfaces touched by this slice while preserving entitled read access to management and historical viewers. +- **FR-005**: The admin plane MUST show read-only closure posture for an affected workspace without exposing a second closure-mutation plane. +- **FR-006**: The system MUST model tenant removal from workspace as explicit product truth distinct from tenant archive and distinct from provider-missing lifecycle. +- **FR-007**: Authorized workspace actors MUST be able to remove and restore a tenant from the workspace through explicit confirmation flows that capture a reason and write audit events. +- **FR-008**: A removed tenant MUST stop being selectable in `/admin/choose-tenant` and MUST stop being a valid tenant-context route target under `/admin/t/{tenant}/...`. +- **FR-009**: A removed tenant MUST remain inspectable on tenant-management and canonical historical viewer surfaces when the actor remains entitled to the workspace and tenant history. +- **FR-010**: Canonical historical viewers in scope for this v1, specifically system run viewers and system workspace or tenant historical detail viewers, MUST remain viewable when they reference a closed workspace or removed tenant and entitlement still allows access. +- **FR-011**: The system MUST preserve existing workspace-membership and tenant-membership records during close/reopen and remove/restore flows in this v1 slice; no membership purge or recreation workflow is allowed. +- **FR-012**: The product MUST distinguish `Suspended read-only`, `Closed`, and `Removed from workspace` with separate badge semantics, copy, and blocked-action explanations. +- **FR-013**: Chooser and context-recovery surfaces MUST explain why a remembered context was cleared and MUST offer one valid next action rather than silently redirecting. +- **FR-014**: Authorization on affected surfaces MUST preserve `404` for non-members or wrong-plane actors and `403` for in-scope actors missing capability. +- **FR-015**: Lifecycle mutations in this feature MUST be labeled consistently as `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant` across buttons, modals, notifications, and audit prose. +- **FR-016**: Close/reopen and remove/restore actions MUST be TenantPilot-only mutations; no Microsoft tenant or provider mutation is performed in this slice. +- **FR-017**: Blocked starts caused by closed workspaces or removed tenants on the in-scope tenant start surface MUST not create `OperationRun` records, local blocked-run substitutes, or new notification families. +- **FR-018**: The feature MUST preserve current owner-guard and membership-safety behavior rather than bypassing it through lifecycle mutations. +- **FR-019**: This feature MUST not widen discovery: existing global-search and list entitlement rules remain unchanged, and no new discovery path may leak closed or removed records to actors who are not already entitled to inspect them. +- **FR-020**: The feature MUST not add purge, export-before-delete, retention execution, or billing-provider logic. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds or modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), RBAC gating (capability + enforcement helper), 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System workspace detail | `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` | `Close workspace` or `Reopen workspace` | Route-record detail | none | none | n/a | `Close workspace` or `Reopen workspace` | n/a | yes | Platform capability only; confirmation required; no second control plane on `/admin` | +| Admin workspace detail | `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` | none added in this slice | Route-record detail | none | none | n/a | none for closure on admin plane | Save/cancel unchanged where edit already exists | no direct mutation | Read-only summary only | +| Managed tenant list | `apps/platform/app/Filament/Resources/TenantResource.php` list page | none beyond current list controls | Full-row click to tenant detail | current safe shortcuts only; `Remove tenant` or `Restore tenant` lives in `More` | none in this slice | existing tenant-creation CTA stays unchanged | n/a | n/a | yes for remove/restore | Destructive-like action grouped to `More` | +| Managed tenant detail | `apps/platform/app/Filament/Resources/TenantResource.php` view page | `Remove tenant` or `Restore tenant` plus existing safe navigation | Route-record detail | none | none | n/a | `Remove tenant` or `Restore tenant` | Save/cancel unchanged where edit already exists | yes | Owner or equivalent capability only; confirmation required | +| Workspace chooser | `apps/platform/app/Filament/Pages/ChooseWorkspace.php` | none | chooser row or card select | `Choose workspace` | none | existing empty-state CTA only if no valid workspace exists | n/a | n/a | no | Closed workspaces excluded from selectable set | +| Tenant chooser | `apps/platform/app/Filament/Pages/ChooseTenant.php` | none | chooser row or card select | `Choose tenant` for active tenants only | none | existing empty-state CTA only if no valid tenant exists | n/a | n/a | no | Removed tenants excluded from selectable set | + +### Key Entities *(include if feature involves data)* + +- **Workspace closure truth**: Explicit lifecycle posture on `Workspace` that marks a workspace as closed or reopened and records the reason and actor without deleting the workspace. +- **Tenant removal truth**: Explicit lifecycle posture on `ManagedEnvironment` that marks a tenant as removed from the active workspace set or restored without deleting the tenant record. +- **Remembered workspace and tenant context**: Convenience selection state that must be cleared when closure or removal makes it invalid. +- **Canonical historical viewer in scope**: Existing system run viewers plus system workspace and tenant detail viewers that remain readable even when active context becomes invalid. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In validation scenarios, 100% of close/reopen and remove/restore actions show explicit posture and impact summary with no ambiguous fallback to `Archived`. +- **SC-002**: In validation scenarios, 0 closed workspaces and 0 removed tenants appear as selectable choices in the normal workspace and tenant choosers. +- **SC-003**: In validation scenarios, 100% of entitled system run viewers and in-scope system historical detail viewers remain accessible when they reference a closed workspace or removed tenant. +- **SC-004**: In validation scenarios, 100% of blocked actions explain whether the blocker is `Suspended read-only`, `Closed`, or `Removed from workspace`. +- **SC-005**: In validation scenarios, 100% of lifecycle mutations covered by this slice write audit events that include actor, prior state, new state, timestamp, and reason. + +## Summary + +Workspace closure and tenant removal are the next runtime follow-through after the lifecycle taxonomy foundation from Spec 262. The feature does not attempt deletion, purge, or export. It introduces one explicit workspace-level closure truth, one explicit tenant removed-from-workspace truth, and the minimum surface, chooser, and canonical-view behavior needed to make those truths operable and auditable. + +The key product distinction is that commercial suspension, explicit workspace closure, tenant removal from workspace, tenant archive, and provider-missing lifecycle are not the same thing. This feature productizes the first two missing runtime decisions without reopening the broader lifecycle package. + +## Goals + +- Let platform users close and reopen workspaces deliberately and auditably. +- Let workspace owners remove and restore tenants from the active workspace set without deleting history. +- Make chooser, context-recovery, and canonical-view behavior explicit when closure or removal invalidates active context. +- Keep suspended read-only posture distinct from explicit closure and tenant removal. +- Reuse current Filament, audit, chooser, and canonical-view seams rather than introducing a new lifecycle framework. + +## Non-Goals + +- No purge, export-before-delete, or retention-execution workflow. +- No hard delete or membership-deletion cascade. +- No payment-provider or billing-portal work. +- No reopening of broader provider lifecycle or retention taxonomy work. +- No new panel, global-search resource, or custom UI system. + +## Assumptions + +- `WorkspaceCommercialLifecycleResolver` remains the source of commercial suspension truth and is not replaced by closure logic. +- Workspace memberships and tenant memberships remain the entitlement backbone even when a workspace is closed or a tenant is removed. +- Existing canonical historical viewers already have legitimate workspace identity and can keep rendering when lifecycle posture changes. +- Close/reopen and remove/restore are TenantPilot-only mutations in this v1. + +## Risks + +- Closure and removal could be mistakenly folded into existing archive semantics if the implementation does not keep badges, copy, and blocked-action explanations distinct. +- Chooser recovery could regress into silent redirects if the feature does not explicitly handle cleared context. +- Canonical historical viewers could accidentally inherit active-context requirements and start returning false not-found errors. +- The slice could widen into purge, export, or billing-provider logic unless the follow-up boundaries stay explicit. + +## Follow-Up Work + +- Export-before-delete and retention/purge governance remain separate follow-up specs. +- Any future customer self-service offboarding or billing-driven closure workflow remains a separate commercial or portal slice. +- Broader lifecycle alignment for other governed objects remains outside this workspace and tenant closure follow-through. + +## Final Direction + +The intended runtime contract is: a suspended workspace stays readable but commercially blocked, a closed workspace becomes non-selectable and read-only for entitled actors, and a removed tenant stops being an active workspace context without losing historical legitimacy. The implementation should add only the truth and behavior needed to make those distinctions real. \ No newline at end of file diff --git a/specs/292-workspace-tenant-closure/tasks.md b/specs/292-workspace-tenant-closure/tasks.md new file mode 100644 index 00000000..bdda505f --- /dev/null +++ b/specs/292-workspace-tenant-closure/tasks.md @@ -0,0 +1,182 @@ +--- +description: "Task list for Workspace & Tenant Closure Lifecycle v1" +--- + +# Tasks: Workspace & Tenant Closure Lifecycle v1 + +**Input**: Design documents from `specs/292-workspace-tenant-closure/` +**Prerequisites**: `specs/292-workspace-tenant-closure/spec.md`, `specs/292-workspace-tenant-closure/plan.md` + +**Tests**: REQUIRED (Pest). Keep proof bounded to focused `Feature` coverage for system directory, admin workspace and tenant surfaces, chooser recovery, and canonical historical viewers. +**Operations**: Reuse the existing `OperationRun` start UX and canonical run viewers. No new run type, no queue family, and no local blocked-run substitute are allowed. +**RBAC**: Wrong-plane or non-member access remains `404`; in-scope actors missing capability remain `403`. Closure/removal posture is never an authorization shortcut. +**Shared Pattern Reuse**: Reuse `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, `BadgeCatalog` / `BadgeRenderer`, current audit infrastructure, and current Filament action-surface patterns. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed. +**Organization**: Tasks are grouped by user story so workspace closure, tenant removal, and posture clarity remain independently implementable and testable. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof. +- [x] New or changed tests stay in focused `Feature` families only unless a bounded implementation seam proves a unit test is necessary. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. +- [x] Planned validation commands cover closure, removal, chooser recovery, and historical viewer legitimacy without widening into browser or heavy-governance lanes. +- [x] The declared surface test profiles remain `standard-native-filament`, `global-context-shell`, and `shared-detail-family` only. +- [x] Any drift toward purge, export, billing workflow, or a second mutation plane resolves as `reject-or-split`, not hidden scope. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the current lifecycle, chooser, and history seams before any runtime change begins. + +- [x] T001 Review `specs/292-workspace-tenant-closure/spec.md`, `specs/292-workspace-tenant-closure/plan.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, `specs/143-tenant-lifecycle-operability-context-semantics/spec.md`, and `specs/274-billing-subscription-truth/spec.md` together so the slice stays grounded in current lifecycle and commercial truth. +- [x] T002 [P] Confirm the current system and admin surface seams in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`. +- [x] T003 [P] Confirm the chooser and context seams in `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, and `apps/platform/app/Filament/Pages/ChooseTenant.php`. +- [x] T004 [P] Confirm the current lifecycle and audit seams in `apps/platform/app/Services/Tenants/TenantOperabilityService.php`, `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and the current tenant audit logging path. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the bounded lifecycle truth and write-side seam before surface behavior changes. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T005 [P] Add failing feature coverage in `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php` and `apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` to lock close/reopen behavior, historical readability, and pre-enqueue blocking. +- [x] T006 [P] Add failing feature coverage in `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php` and `apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` to lock admin read-only posture, chooser recovery, and cleared-context behavior. +- [x] T007 [P] Add failing feature coverage in `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to lock remove/restore behavior, chooser exclusion, and tenant-context denial rules. +- [x] T008 Create `apps/platform/database/migrations/*_add_workspace_closure_fields.php` and `apps/platform/database/migrations/*_add_managed_environment_workspace_removal_fields.php` so existing records can store explicit closure and removal truth. +- [x] T009 Update `apps/platform/app/Models/Workspace.php` and `apps/platform/app/Models/ManagedEnvironment.php` with casts, bounded helper methods, and relationship accessors for the new lifecycle truth. +- [x] T010 Implement `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php` as the one bounded orchestration seam for close/reopen and remove/restore plus audit-safe state transitions, and keep membership rows preserved rather than recreated or deleted. +- [x] T011 Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and the current tenant audit logging path with stable action IDs and metadata for close/reopen and remove/restore. +- [x] T012 Update the relevant policy and capability seams so close/reopen and remove/restore enforce server-side authorization with the required `404` versus `403` behavior. + +**Checkpoint**: Workspace and tenant lifecycle truth exists, audit is wired, and write-side behavior is centralized before UI or chooser changes land. + +--- + +## Phase 3: User Story 1 - Close a workspace without losing history (Priority: P1) + +**Goal**: Authorized platform users can close and reopen workspaces explicitly while preserving readable history. + +**Independent Test**: Close a workspace from the system detail page, confirm chooser and action gating update, verify historical viewers remain readable, and reopen the workspace. + +### Tests for User Story 1 + +- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php` to prove confirmation, impact summary, reason capture, clear guard-failure reasons on unsafe close attempts, canonical success or error notification copy, audit metadata, membership preservation, reopen behavior, and blocked mutation or start behavior. +- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php` to prove admin read-only posture, distinct closed versus suspended copy, and the absence of a second workspace-closure mutation plane. + +### Implementation for User Story 1 + +- [x] T015 [US1] Update `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` so the system surfaces render closure posture, impact summary, and keep closed-workspace history readable with canonical success or error notification copy and clear guard-failure messaging on unsafe attempts. +- [x] T016 [US1] Update `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` so closed workspaces clear invalid remembered context, route through explicit recovery messaging, and block the in-scope tenant start surface before enqueue. +- [x] T017 [US1] Update `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so the admin plane shows read-only closure posture, defers in-scope blocked-state checks to `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php`, and does not expose closure mutation controls. + +**Checkpoint**: Workspace closure becomes an explicit, auditable, read-only posture with preserved historical access. + +--- + +## Phase 4: User Story 2 - Remove a tenant from a workspace without deleting tenant history (Priority: P1) + +**Goal**: Workspace owners can remove and restore tenants from the active workspace set without losing historical legitimacy. + +**Independent Test**: Remove a tenant from the tenant-management surface, confirm chooser and tenant-context routes treat it as non-operable, verify historical viewers still render, and restore the tenant. + +### Tests for User Story 2 + +- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to prove confirmation, impact summary, reason capture, clear guard-failure reasons on unsafe remove attempts, canonical success or error notification copy, audit metadata, membership preservation, restore behavior, chooser exclusion, tenant-context denial, and representative removed-tenant blocked-start no-`OperationRun` behavior on `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`. +- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` to prove historical viewers remain readable when the referenced tenant is removed from the workspace. + +### Implementation for User Story 2 + +- [x] T020 [US2] Update `apps/platform/app/Filament/Resources/TenantResource.php` and the resource's view-page action surface so remove/restore actions, impact summaries, canonical success or error notification copy, posture badges, and grouped destructive actions follow the spec contract. +- [x] T021 [US2] Update `apps/platform/app/Services/Tenants/TenantOperabilityService.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, tenant-memory handling in `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` so removed tenants are no longer valid active context and cannot enqueue new runs. +- [x] T022 [US2] Update `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` and `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` so removed tenants remain historically visible with explicit posture and no false not-found behavior. + +**Checkpoint**: Tenant removal becomes explicit, reversible, and historically safe without remaining an active workspace context. + +--- + +## Phase 5: User Story 3 - Distinguish suspended read-only, closed, and removed clearly (Priority: P2) + +**Goal**: Operators can tell which lifecycle posture is blocking them and what next action remains legitimate. + +**Independent Test**: Render admin, system, chooser, and historical surfaces for suspended-read-only, closed, and removed states and verify distinct copy and badges. + +### Tests for User Story 3 + +- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php`, `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php`, and `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to prove distinct posture labels, centralized badge mapping, disclosure ordering, blocked-action explanations, and one dominant next action. + +### Implementation for User Story 3 + +- [x] T024 [US3] Update posture rendering on `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`, and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so `Suspended read-only` remains distinct from `Closed` and uses shared badge semantics rather than local mappings. +- [x] T025 [US3] Update posture rendering on `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, and chooser recovery messaging so `Removed from workspace` remains distinct from archive and provider-missing semantics, and keep decision content first with diagnostics secondary. + +**Checkpoint**: Lifecycle posture becomes explicit and non-ambiguous across the affected operator and history surfaces. + +--- + +## Phase 6: Polish & Cross-Cutting Validation + +**Purpose**: Validate the bounded slice and stop without widening scope. + +- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php`. +- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php`. +- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T029 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource or wider discovery path is added, no asset strategy changes appear, and no second closure-mutation plane slipped in. +- [x] T030 [P] Record the final guardrail and test-governance outcome in the implementation close-out without reopening purge, export, billing, or portal scope. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes explicit workspace closure truth plus chooser recovery. +- **Phase 4 (US2)**: depends on Phase 2 and should land after or alongside US1 so tenant removal composes with the new workspace posture rules. +- **Phase 5 (US3)**: depends on Phases 3 and 4 because it clarifies the final shared posture language. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and delivers the central workspace-closure capability. +- **US2 (P1)**: independently testable after Phase 2 and delivers the central tenant-removal capability. +- **US3 (P2)**: depends on the completed runtime posture rules from US1 and US2. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap. +- Keep implementation inside the existing model, service, middleware, Filament, and audit seams named above. +- Re-run the narrowest relevant validation command after each story checkpoint before moving on. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. The feature is only trustworthy when workspace closure and tenant removal both exist and historical readability remains intact. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 so explicit workspace closure and chooser recovery exist. +3. Deliver US2 so tenants can be removed or restored without losing history. +4. Deliver US3 so posture language across the affected surfaces becomes unambiguous. +5. Finish with the focused validation and drift-review tasks in Phase 6. + +### Team Strategy + +1. Settle persistence and bounded service shape first. +2. Parallelize failing tests within each story before runtime edits. +3. Serialize merges around `WorkspaceContext`, `TenantOperabilityService`, `ViewWorkspace`, and `TenantResource` so posture language stays coherent. + +--- + +## Deferred Follow-Ups / Non-Goals + +- export-before-delete workflow +- retention and purge governance +- customer self-serve workspace offboarding or billing-driven closure +- provider-level lifecycle expansion beyond the current separate specs +- lifecycle dashboard or workbench surfaces \ No newline at end of file From 2952e5ad3e06a34ed5e0c943318c4df8e0452dad Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 7 May 2026 16:55:17 +0000 Subject: [PATCH 3/4] feat: polish tenant dashboard operations attention UX (#338) ## Summary - rename the tenant dashboard operations KPI to attention-first wording and keep the primary header CTA derived from the highest-priority recommended action - restyle the `Operations requiring attention` card to match the existing neutral dashboard card language while keeping only a subtle per-item attention accent - replace technical operation identifiers on the dashboard with calmer timing/copy, including provider-consent follow-up messaging for blocked permission posture checks - refresh the local Spec Kit artifacts for spec 273 so the branch documentation matches the implemented attention-only dashboard scope ## Validation - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/338 --- .../Dashboard/TenantDashboardOverview.php | 1 + .../TenantDashboardSummary.php | 4 + .../TenantDashboardSummaryBuilder.php | 286 ++++++++++++++-- apps/platform/lang/de/localization.php | 37 ++- apps/platform/lang/en/localization.php | 37 ++- .../tenant-dashboard-overview.blade.php | 117 ++++--- ...TenantDashboardProductizationSmokeTest.php | 22 +- ...nantDashboardProductizationActionsTest.php | 78 +++++ ...shboardProductizationAuthorizationTest.php | 32 ++ ...nantDashboardProductizationSummaryTest.php | 160 +++++++-- .../Filament/DashboardKpisWidgetTest.php | 4 +- .../Filament/TenantDashboardDbOnlyTest.php | 23 +- .../checklists/requirements.md | 56 ++++ .../plan.md | 100 ++++++ .../spec.md | 305 ++++++++++++++++++ .../tasks.md | 75 +++++ 16 files changed, 1204 insertions(+), 133 deletions(-) create mode 100644 specs/273-tenant-dashboard-active-operations-summary-card/checklists/requirements.md create mode 100644 specs/273-tenant-dashboard-active-operations-summary-card/plan.md create mode 100644 specs/273-tenant-dashboard-active-operations-summary-card/spec.md create mode 100644 specs/273-tenant-dashboard-active-operations-summary-card/tasks.md diff --git a/apps/platform/app/Filament/Widgets/Dashboard/TenantDashboardOverview.php b/apps/platform/app/Filament/Widgets/Dashboard/TenantDashboardOverview.php index 9319c6d3..a01a3b79 100644 --- a/apps/platform/app/Filament/Widgets/Dashboard/TenantDashboardOverview.php +++ b/apps/platform/app/Filament/Widgets/Dashboard/TenantDashboardOverview.php @@ -43,6 +43,7 @@ protected function getViewData(): array 'recommendedActions' => [], 'governanceStatus' => [], 'readinessCards' => [], + 'activeOperationSummary' => null, 'recentOperations' => [], 'pollingInterval' => null, ]; diff --git a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummary.php b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummary.php index 5e8bd4f0..f2734846 100644 --- a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummary.php +++ b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummary.php @@ -13,6 +13,7 @@ * @param list> $recommendedActions * @param list> $governanceStatus * @param list> $readinessCards + * @param array|null $activeOperationSummary * @param list> $recentOperations */ public function __construct( @@ -22,6 +23,7 @@ public function __construct( public array $recommendedActions, public array $governanceStatus, public array $readinessCards, + public ?array $activeOperationSummary, public array $recentOperations, public ?string $pollingInterval, ) {} @@ -34,6 +36,7 @@ public function __construct( * recommendedActions: list>, * governanceStatus: list>, * readinessCards: list>, + * activeOperationSummary: array|null, * recentOperations: list>, * pollingInterval: ?string, * } @@ -47,6 +50,7 @@ public function toArray(): array 'recommendedActions' => $this->recommendedActions, 'governanceStatus' => $this->governanceStatus, 'readinessCards' => $this->readinessCards, + 'activeOperationSummary' => $this->activeOperationSummary, 'recentOperations' => $this->recentOperations, 'pollingInterval' => $this->pollingInterval, ]; diff --git a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php index e31c54f4..c2042e47 100644 --- a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php +++ b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php @@ -33,6 +33,7 @@ use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Links\RequiredPermissionsLinks; use App\Support\OperationCatalog; +use App\Support\OperationRunOutcome; use App\Support\OperationRunLinks; use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\OperationUxPresenter; @@ -42,6 +43,7 @@ use App\Support\Verification\VerificationReportOverall; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Str; final class TenantDashboardSummaryBuilder @@ -142,6 +144,7 @@ public function build(ManagedEnvironment $tenant, ?User $user = null): TenantDas latestEvidenceSnapshot: $latestEvidenceSnapshot, exceptionStats: $exceptionStats, ), + activeOperationSummary: $this->activeOperationSummary($tenant, $user), recentOperations: $this->recentOperationCards($tenant, $recentOperations), pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant), ); @@ -370,12 +373,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA $highSeverityChart = $this->highSeverityFindingsChart($tenant); $operationsFollowUpChart = $this->operationsFollowUpChart($tenant); - $operationsNeedingFollowUp = (int) OperationRun::query() - ->where('managed_environment_id', (int) $tenant->getKey()) - ->where(function ($query): void { - $query->terminalFollowUp()->orWhere(fn ($inner) => $inner->activeStaleAttention()); - }) - ->count(); + $operationsNeedingFollowUp = (int) $this->operationsRequiringAttentionQuery($tenant)->count(); return [ $this->metricCard( @@ -424,7 +422,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA action: $this->operationsAction( tenant: $tenant, user: $user, - label: $this->overviewText('action_view_all_operations'), + label: $this->overviewText('action_open_operations_hub'), activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active', problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null, ), @@ -497,13 +495,8 @@ private function activeOperationsKpiDescription(int $count, ?array $chart): stri return $this->overviewText('kpi_active_operations_tendency_none'); } - $windowCount = $chart === null ? 0 : array_sum($chart); - - if ($windowCount > 0) { - return $this->overviewText('kpi_active_operations_tendency_window', [ - 'count' => $count, - 'window' => $windowCount, - ]); + if ($count === 1) { + return $this->overviewText('kpi_active_operations_tendency_one'); } return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]); @@ -548,9 +541,7 @@ private function highSeverityFindingsChart(ManagedEnvironment $tenant): ?array private function operationsFollowUpChart(ManagedEnvironment $tenant): ?array { $window = $this->sevenDayWindow(); - $byDay = OperationRun::query() - ->where('managed_environment_id', (int) $tenant->getKey()) - ->dashboardNeedsFollowUp() + $byDay = $this->operationsRequiringAttentionQuery($tenant) ->where(function (Builder $query) use ($window): void { $query ->whereBetween('completed_at', [$window['start'], $window['end']]) @@ -628,21 +619,21 @@ private function recommendedActions( $candidates[] = $this->actionCandidate( priority: 10, key: 'required_permissions', - title: $this->overviewText('action_open_required_permissions'), + title: $this->overviewText('action_review_permissions'), reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]), impact: $this->overviewText('impact_missing_application_permissions'), tone: 'danger', - action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')), + action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')), ); } elseif ($missingDelegatedPermissions > 0) { $candidates[] = $this->actionCandidate( priority: 20, key: 'delegated_permissions', - title: $this->overviewText('action_open_required_permissions'), + title: $this->overviewText('action_review_permissions'), reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]), impact: $this->overviewText('impact_missing_delegated_permissions'), tone: 'warning', - action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')), + action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')), ); } @@ -701,25 +692,24 @@ private function recommendedActions( ); } - $terminalFollowUpRuns = (int) OperationRun::query() - ->where('managed_environment_id', (int) $tenant->getKey()) - ->terminalFollowUp() - ->count(); + $operationsRequiringAttention = $this->operationsRequiringAttentionRuns($tenant); + + if ($operationsRequiringAttention->isNotEmpty()) { + $dominantProblemClass = $this->dominantAttentionProblemClass($operationsRequiringAttention); - if ($terminalFollowUpRuns > 0) { $candidates[] = $this->actionCandidate( - priority: 70, - key: 'terminal_operations', - title: $this->overviewText('action_view_all_operations'), - reason: $this->overviewText('reason_terminal_operations', ['count' => $terminalFollowUpRuns]), - impact: $this->overviewText('impact_terminal_operations'), + priority: 35, + key: 'operations_requiring_attention', + title: $this->overviewText('action_review_operations_requiring_attention'), + reason: $this->overviewText('reason_operations_requiring_attention'), + impact: $this->overviewText('impact_operations_requiring_attention'), tone: 'danger', action: $this->operationsAction( tenant: $tenant, user: $user, - label: $this->overviewText('action_view_all_operations'), - activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, - problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, + label: $this->overviewText('action_review_operations'), + activeTab: $dominantProblemClass, + problemClass: $dominantProblemClass, ), ); } @@ -993,6 +983,68 @@ private function recentOperationCards(ManagedEnvironment $tenant, array $recentO ->all(); } + /** + * @return array|null + */ + private function activeOperationSummary(ManagedEnvironment $tenant, ?User $user): ?array + { + if (! $user instanceof User || ! $user->canAccessTenant($tenant)) { + return null; + } + + $qualifyingRuns = $this->operationsRequiringAttentionRuns($tenant); + + if ($qualifyingRuns->isEmpty()) { + return null; + } + + $dominantProblemClass = $this->dominantAttentionProblemClass($qualifyingRuns); + + return [ + 'title' => $this->overviewText('operations_attention_title'), + 'count' => $qualifyingRuns->count(), + 'tone' => 'warning', + 'secondaryActionLabel' => $this->overviewText('action_open_operations_hub'), + 'secondaryActionUrl' => OperationRunLinks::index( + $tenant, + activeTab: $dominantProblemClass, + problemClass: $dominantProblemClass, + ), + 'items' => $this->attentionOperationItems($qualifyingRuns, $tenant), + ]; + } + + private function compareActiveOperationSummaryRuns(OperationRun $left, OperationRun $right): int + { + $priorityComparison = $this->activeOperationSummaryPriority($left) <=> $this->activeOperationSummaryPriority($right); + + if ($priorityComparison !== 0) { + return $priorityComparison; + } + + $timestampComparison = $this->activeOperationSummaryTimestamp($right) <=> $this->activeOperationSummaryTimestamp($left); + + if ($timestampComparison !== 0) { + return $timestampComparison; + } + + return ((int) $right->getKey()) <=> ((int) $left->getKey()); + } + + private function activeOperationSummaryPriority(OperationRun $run): int + { + return match ($run->problemClass()) { + OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 0, + OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 1, + default => 2, + }; + } + + private function activeOperationSummaryTimestamp(OperationRun $run): int + { + return ($run->completed_at ?? $run->started_at ?? $run->created_at)?->getTimestamp() ?? 0; + } + private function governanceStatusIcon(string $key): string { return match ($key) { @@ -1067,7 +1119,7 @@ private function recommendedActionIcon(string $key): string return match ($key) { 'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation', 'overdue_findings' => 'heroicon-o-clock', - 'recovery_posture', 'terminal_operations', 'continue_review' => 'heroicon-o-arrow-path-rounded-square', + 'recovery_posture', 'operations_requiring_attention', 'continue_review' => 'heroicon-o-arrow-path-rounded-square', 'risk_exceptions' => 'heroicon-o-exclamation-triangle', default => 'heroicon-o-exclamation-triangle', }; @@ -1220,6 +1272,170 @@ private function operationsAction(ManagedEnvironment $tenant, ?User $user, strin ); } + private function operationsRequiringAttentionQuery(ManagedEnvironment $tenant): Builder + { + return OperationRun::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->dashboardNeedsFollowUp(); + } + + /** + * @return Collection + */ + private function operationsRequiringAttentionRuns(ManagedEnvironment $tenant): Collection + { + return $this->operationsRequiringAttentionQuery($tenant) + ->get() + ->sort(fn (OperationRun $left, OperationRun $right): int => $this->compareActiveOperationSummaryRuns($left, $right)) + ->values(); + } + + /** + * @param Collection $runs + * @return list> + */ + private function attentionOperationItems(Collection $runs, ManagedEnvironment $tenant): array + { + return $runs + ->take(3) + ->map(function (OperationRun $run) use ($tenant): array { + $statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [ + 'status' => (string) $run->status, + 'freshness_state' => $run->freshnessState()->value, + ]); + $outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [ + 'outcome' => (string) $run->outcome, + 'status' => (string) $run->status, + 'freshness_state' => $run->freshnessState()->value, + ]); + + return [ + 'id' => (int) $run->getKey(), + 'identifier' => OperationRunLinks::identifier($run), + 'type' => OperationCatalog::label((string) $run->type), + 'title' => $this->attentionOperationTitle($run), + 'icon' => $this->recentOperationIcon((string) $run->type), + 'attentionLabel' => $this->attentionOperationBadgeLabel($run), + 'problemClass' => $run->problemClass(), + 'problemClassLabel' => OperationUxPresenter::problemClassLabel($run), + 'statusLabel' => $statusSpec->label, + 'statusTone' => $statusSpec->color, + 'outcomeLabel' => $outcomeSpec->label, + 'outcomeTone' => $outcomeSpec->color, + 'outcomeSentence' => $this->attentionOperationOutcomeSentence($run), + 'reason' => $this->attentionOperationReason($run), + 'impact' => $this->attentionOperationImpact($run), + 'timingLabel' => $this->attentionOperationTimingLabel($run), + 'createdAt' => $run->completed_at?->diffForHumans() ?? $run->created_at?->diffForHumans(), + 'primaryActionLabel' => $this->overviewText('action_review_operation'), + 'primaryActionUrl' => OperationRunLinks::view($run, $tenant), + ]; + }) + ->values() + ->all(); + } + + /** + * @param Collection $runs + */ + private function dominantAttentionProblemClass(Collection $runs): string + { + return $runs->contains(fn (OperationRun $run): bool => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) + ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + : OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION; + } + + private function attentionOperationTitle(OperationRun $run): string + { + return OperationCatalog::label((string) $run->type); + } + + private function attentionOperationBadgeLabel(OperationRun $run): string + { + return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION + ? $this->overviewText('operations_attention_badge_stale') + : $this->overviewText('operations_attention_badge_follow_up'); + } + + private function attentionOperationOutcomeSentence(OperationRun $run): string + { + if ($run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) { + return $this->overviewText('operations_attention_outcome_stale'); + } + + if ($this->isProviderConsentBlockedRun($run)) { + return $this->overviewText('operations_attention_outcome_provider_consent_required'); + } + + return match ((string) $run->outcome) { + OperationRunOutcome::Blocked->value => $this->overviewText('operations_attention_outcome_blocked'), + OperationRunOutcome::PartiallySucceeded->value => $this->overviewText('operations_attention_outcome_partial'), + OperationRunOutcome::Failed->value => $this->overviewText('operations_attention_outcome_failed'), + default => $this->overviewText('operations_attention_outcome_generic'), + }; + } + + private function attentionOperationReason(OperationRun $run): string + { + if ($this->isProviderConsentBlockedRun($run)) { + return $this->overviewText('operations_attention_reason_provider_consent_required'); + } + + $operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run); + $reason = trim((string) ($operatorExplanation?->dominantCauseExplanation ?? '')); + + if ($reason !== '') { + return $reason; + } + + $failureDetail = trim((string) (OperationUxPresenter::surfaceFailureDetail($run) ?? '')); + + if ($failureDetail !== '') { + return $failureDetail; + } + + return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION + ? $this->overviewText('operations_attention_reason_stale') + : $this->overviewText('operations_attention_reason_fallback'); + } + + private function attentionOperationImpact(OperationRun $run): string + { + if ($this->isProviderConsentBlockedRun($run)) { + return $this->overviewText('operations_attention_impact_provider_consent_required'); + } + + return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION + ? $this->overviewText('operations_attention_impact_stale') + : $this->overviewText('operations_attention_impact_follow_up'); + } + + private function attentionOperationTimingLabel(OperationRun $run): ?string + { + if ($run->completed_at instanceof Carbon) { + return $this->overviewText('operations_attention_timing_completed', [ + 'time' => $run->completed_at->diffForHumans(), + ]); + } + + $reference = $run->started_at ?? $run->created_at; + + if (! $reference instanceof Carbon) { + return null; + } + + return $this->overviewText('operations_attention_timing_started', [ + 'time' => $reference->diffForHumans(), + ]); + } + + private function isProviderConsentBlockedRun(OperationRun $run): bool + { + return OperationCatalog::canonicalCode((string) $run->type) === OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK + && (string) $run->outcome === OperationRunOutcome::Blocked->value; + } + /** * @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string} */ diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index d96790ce..67d579a1 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -173,17 +173,25 @@ 'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen', 'kpi_missing_permissions_tendency_delegated_only' => ':count delegierte Berechtigungen fehlen', 'kpi_missing_permissions_tendency_none' => 'Berechtigungen vollständig', - 'kpi_active_operations_label' => 'Aktive Vorgänge', - 'kpi_active_operations_description' => 'Veraltete oder terminale Vorgänge benötigen Operator-Nachverfolgung.', - 'kpi_active_operations_tendency' => ':count mit Follow-up', - 'kpi_active_operations_tendency_window' => ':count mit Follow-up · :window in 7 Tagen', - 'kpi_active_operations_tendency_none' => 'Kein Follow-up offen', + 'kpi_active_operations_label' => 'Vorgänge mit Aufmerksamkeitsbedarf', + 'kpi_active_operations_description' => 'Vorgangsläufe, die noch Follow-up benötigen, bevor der Tenant als gesund gelten kann.', + 'kpi_active_operations_tendency' => ':count Vorgänge erfordern Aufmerksamkeit', + 'kpi_active_operations_tendency_window' => ':count Vorgänge erfordern Aufmerksamkeit', + 'kpi_active_operations_tendency_one' => '1 Vorgang benötigt Follow-up', + 'kpi_active_operations_tendency_none' => 'Keine Vorgänge benötigen Aufmerksamkeit', 'action_review_findings' => 'Findings prüfen', 'action_open_overdue_findings' => 'Überfällige Findings öffnen', + 'action_review_permissions' => 'Berechtigungen prüfen', 'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen', 'action_review_risks' => 'Risiken prüfen', 'action_review_recovery_posture' => 'Wiederherstellungsstatus prüfen', 'action_view_all_operations' => 'Alle Vorgänge anzeigen', + 'action_view_operation' => 'Vorgang anzeigen', + 'action_review_operation' => 'Vorgang prüfen', + 'action_review_operations' => 'Vorgänge prüfen', + 'action_review_operations_requiring_attention' => 'Aufmerksamkeitspflichtige Vorgänge prüfen', + 'action_open_operations_hub' => 'Operations-Hub öffnen', + 'action_show_all_operations' => 'Alle Vorgänge anzeigen', 'action_open_governance_inbox' => 'Governance Inbox öffnen', 'action_continue_review' => 'Review fortsetzen', 'action_open_baseline_compare' => 'Baseline Compare öffnen', @@ -207,8 +215,27 @@ 'impact_recovery_posture' => 'Die Wiederherstellungsbereitschaft sollte geprüft werden, bevor kundensichere Aussagen auf Backup- oder Restore-Vertrauen beruhen.', 'reason_terminal_operations' => ':count Vorgangslauf/Läufe endeten blockiert, teilweise oder fehlgeschlagen.', 'impact_terminal_operations' => 'Terminale Laufergebnisse benötigen Nachverfolgung, bevor der Tenant als ruhig gelten kann.', + 'reason_operations_requiring_attention' => 'Ein oder mehrere Vorgänge endeten mit einem Ergebnis, das Follow-up benötigt.', + 'impact_operations_requiring_attention' => 'Der Tenant sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.', 'reason_continue_review' => 'Kundensichere Ausgabe ist noch nicht vollständig bereit.', 'impact_continue_review' => 'Die Review-Ausgabe bleibt teilweise, bis Review-, Nachweis- und Paketflächen sauber zusammenpassen.', + 'operations_attention_title' => 'Vorgänge mit Aufmerksamkeitsbedarf', + 'operations_attention_badge_follow_up' => 'Follow-up erforderlich', + 'operations_attention_badge_stale' => 'Aufmerksamkeit nötig', + 'operations_attention_outcome_blocked' => 'Der Vorgang wurde beendet, aber eine Voraussetzung hat den Abschluss blockiert.', + 'operations_attention_outcome_partial' => 'Der Vorgang wurde beendet, aber es ist weiterhin Follow-up erforderlich.', + 'operations_attention_outcome_failed' => 'Der Vorgang endete mit einem Fehler, der geprüft werden muss.', + 'operations_attention_outcome_generic' => 'Der Vorgang endete mit einem Ergebnis, das Nachverfolgung benötigt.', + 'operations_attention_outcome_stale' => 'Der Vorgang ist noch aktiv, liegt aber außerhalb seines erwarteten Lebenszyklusfensters.', + 'operations_attention_outcome_provider_consent_required' => 'Die Prüfung ist abgeschlossen, aber die Provider-Zustimmung ist noch erforderlich.', + 'operations_attention_reason_fallback' => 'Das aufgezeichnete Ergebnis muss geprüft werden, bevor der Tenant als gesund gelten kann.', + 'operations_attention_reason_stale' => 'Der Lauf liegt außerhalb seines normalen Lebenszyklusfensters und schreitet möglicherweise nicht mehr fort.', + 'operations_attention_reason_provider_consent_required' => 'Eine Admin-Zustimmung ist erforderlich, bevor die Provider-Verbindung verwendet werden kann.', + 'operations_attention_impact_follow_up' => 'Die Tenant-Bereitschaft sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.', + 'operations_attention_impact_stale' => 'Die Tenant-Bereitschaft sollte nicht als aktuell betrachtet werden, bis der blockierte Lauf geprüft wurde.', + 'operations_attention_impact_provider_consent_required' => 'Die Tenant-Bereitschaft kann nicht als gesund betrachtet werden, bis dies geprüft wurde.', + 'operations_attention_timing_completed' => 'Abgeschlossen :time', + 'operations_attention_timing_started' => 'Gestartet :time', 'governance_baseline_compare_label' => 'Baseline Compare', 'governance_baseline_compare_description' => 'Aktueller Compare-Status für die Tenant-Baseline.', 'governance_evidence_coverage_label' => 'Nachweisabdeckung', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index afcbd962..c4f43eca 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -173,17 +173,25 @@ 'kpi_missing_permissions_tendency_app_only' => ':count app missing', 'kpi_missing_permissions_tendency_delegated_only' => ':count delegated missing', 'kpi_missing_permissions_tendency_none' => 'Permission set complete', - 'kpi_active_operations_label' => 'Active operations', - 'kpi_active_operations_description' => 'Stale or terminal operation runs needing operator follow-up.', - 'kpi_active_operations_tendency' => ':count need follow-up', - 'kpi_active_operations_tendency_window' => ':count need follow-up · :window in 7d', - 'kpi_active_operations_tendency_none' => 'No follow-up queued', + 'kpi_active_operations_label' => 'Operations needing attention', + 'kpi_active_operations_description' => 'Operation runs that still need follow-up before the tenant can be treated as healthy.', + 'kpi_active_operations_tendency' => ':count operations require attention', + 'kpi_active_operations_tendency_window' => ':count operations require attention', + 'kpi_active_operations_tendency_one' => '1 operation needs follow-up', + 'kpi_active_operations_tendency_none' => 'No operations need attention', 'action_review_findings' => 'Review findings', 'action_open_overdue_findings' => 'Open overdue findings', + 'action_review_permissions' => 'Review permissions', 'action_open_required_permissions' => 'Open required permissions', 'action_review_risks' => 'Review risks', 'action_review_recovery_posture' => 'Review recovery posture', 'action_view_all_operations' => 'View all operations', + 'action_view_operation' => 'View operation', + 'action_review_operation' => 'Review operation', + 'action_review_operations' => 'Review operations', + 'action_review_operations_requiring_attention' => 'Review operations requiring attention', + 'action_open_operations_hub' => 'Open operations hub', + 'action_show_all_operations' => 'Show all operations', 'action_open_governance_inbox' => 'Open governance inbox', 'action_continue_review' => 'Continue review', 'action_open_baseline_compare' => 'Open Baseline Compare', @@ -207,8 +215,27 @@ 'impact_recovery_posture' => 'Recovery readiness should be checked before customer-safe claims rely on backup or restore confidence.', 'reason_terminal_operations' => ':count operation run(s) finished blocked, partial, or failed.', 'impact_terminal_operations' => 'Terminal run outcomes need follow-up before the tenant can be treated as calm.', + 'reason_operations_requiring_attention' => 'One or more operations finished with an outcome that needs follow-up.', + 'impact_operations_requiring_attention' => 'The tenant should not be treated as fully healthy until the operation outcome has been reviewed.', 'reason_continue_review' => 'Customer-safe output is not fully ready yet.', 'impact_continue_review' => 'Review output stays partial until the review, evidence, and pack surfaces line up cleanly.', + 'operations_attention_title' => 'Operations requiring attention', + 'operations_attention_badge_follow_up' => 'Follow-up required', + 'operations_attention_badge_stale' => 'Needs attention', + 'operations_attention_outcome_blocked' => 'The operation finished, but a prerequisite blocked completion.', + 'operations_attention_outcome_partial' => 'The operation finished, but follow-up is still required.', + 'operations_attention_outcome_failed' => 'The operation finished with a failure that needs review.', + 'operations_attention_outcome_generic' => 'The operation finished with an outcome that needs follow-up.', + 'operations_attention_outcome_stale' => 'The operation is still active, but it is past its expected lifecycle window.', + 'operations_attention_outcome_provider_consent_required' => 'The check finished, but provider consent is still required.', + 'operations_attention_reason_fallback' => 'The recorded outcome still needs operator review before the tenant can be treated as healthy.', + 'operations_attention_reason_stale' => 'The run is past its normal lifecycle window and may no longer be progressing.', + 'operations_attention_reason_provider_consent_required' => 'Admin consent is required before the provider connection can be used.', + 'operations_attention_impact_follow_up' => 'Tenant readiness should not be treated as fully healthy until the operation outcome has been reviewed.', + 'operations_attention_impact_stale' => 'Tenant readiness should not be treated as current until the stalled run has been reviewed.', + 'operations_attention_impact_provider_consent_required' => 'Tenant readiness cannot be treated as healthy until this is reviewed.', + 'operations_attention_timing_completed' => 'Completed :time', + 'operations_attention_timing_started' => 'Started :time', 'governance_baseline_compare_label' => 'Baseline compare', 'governance_baseline_compare_description' => 'Current compare posture for the tenant baseline.', 'governance_evidence_coverage_label' => 'Evidence coverage', diff --git a/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php b/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php index 49d56f91..3e6dab28 100644 --- a/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php +++ b/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php @@ -138,61 +138,82 @@ class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
- - - @if ($recentOperations === []) -
-
{{ __('localization.dashboard.overview.empty_recent_operations_headline') }}
-

- {{ __('localization.dashboard.overview.empty_recent_operations_summary') }} -

-
- @else -
- @foreach (array_slice($recentOperations, 0, 4) as $operation) - @php - $operationTone = match ($operation['outcomeTone']) { - 'danger' => 'border-danger-200 bg-danger-50/10 dark:border-danger-800 dark:bg-danger-500/5', - 'warning' => 'border-warning-200 bg-warning-50/10 dark:border-warning-800 dark:bg-warning-500/5', - default => $overviewSecondaryListRowSurfaceClasses, - }; - @endphp - -
-
-
- @if (filled($operation['icon'] ?? null)) - + @if ($activeOperationSummary) + diff --git a/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php b/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php index 2eca432f..c02ead91 100644 --- a/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php +++ b/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php @@ -25,7 +25,7 @@ 'status' => Finding::STATUS_NEW, ]); - OperationRun::factory()->create([ + $operation = OperationRun::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'inventory_sync', @@ -73,30 +73,42 @@ ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true) ->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true) ->assertSee('Recommended next actions') - ->assertSee('Active operations') + ->assertSee('Operations needing attention') + ->assertSee('Operations requiring attention') + ->assertSee('Review operation') + ->assertSee('Open operations hub') ->assertSee('Current review') ->assertSee('Risk exceptions') ->assertSee('Provider Health') ->assertSee('Customer-safe output') + ->assertDontSee('Recent operations') ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"]').length === 4", true) ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-icon=\"true\"]').length === 4", true) ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-chart=\"true\"]').length === 2", true) ->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status-icon\"]'); return rows.length > 0 && rows.length === icons.length; })()", true) ->assertScript("(() => { const rows = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return rows.length > 0 && rows.every((row) => { const interactive = row.getAttribute('data-governance-interactive') === 'true'; return interactive ? row.tagName === 'A' : row.tagName === 'DIV'; }); })()", true) - ->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...governance, ...operations]; return rows.length > 0 && rows.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true) - ->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...interactiveGovernance, ...operations]; return rows.length > 0 && rows.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true) + ->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return governance.length > 0 && governance.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true) + ->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); return interactiveGovernance.length === 0 || interactiveGovernance.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"high_severity_findings\"][data-kpi-has-chart=\"true\"]') !== null", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"active_operations\"][data-kpi-has-chart=\"true\"]') !== null", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"overdue_findings\"][data-kpi-has-chart=\"true\"]') === null", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"missing_permissions\"][data-kpi-has-chart=\"true\"]') === null", true) ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]').length <= 3", true) ->assertScript("(() => { const actions = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action-icon\"]'); return actions.length === 0 || icons.length === actions.length; })()", true) - ->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation-icon\"]'); return rows.length === icons.length; })()", true) + ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]') !== null", true) + ->assertScript("(() => { const card = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]'); if (! card) return false; return card.className.includes('border-gray-200') && card.className.includes('bg-white') && ! card.className.includes('border-warning-200') && ! card.className.includes('bg-warning-50'); })()", true) + ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-secondary-action\"]') !== null", true) + ->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); return rows.length >= 1 && rows.length <= 3; })()", true) + ->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item-icon\"]'); return rows.length === icons.length; })()", true) + ->assertScript("(() => { const item = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); if (! item) return false; return item.className.includes('border-gray-200') && item.className.includes('border-l-4') && item.className.includes('border-l-warning-400') && ! item.className.includes('border-warning-200') && ! item.className.includes('bg-warning-50'); })()", true) ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-readiness-card\"]').length === 4", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-readiness-card\"][data-readiness-key=\"provider_health\"]') !== null", true) ->assertScript("! document.body.innerHTML.includes('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')", true) ->assertScript("(() => { const overview = document.querySelector('[data-testid=\"tenant-dashboard-overview\"]'); const main = document.querySelector('[data-testid=\"tenant-dashboard-overview-main\"]'); if (! overview || ! main) return false; const overviewWidth = overview.getBoundingClientRect().width; const mainWidth = main.getBoundingClientRect().width; return overviewWidth >= 600 && mainWidth >= 400; })()", true) ->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-overview\"] table').length === 0", true) + ->click('Review operation') + ->waitForText('Show all operations') + ->assertScript("window.location.pathname.includes('/admin/operations/{$operation->getKey()}')", true) ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php index 1b825128..fddb1006 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php @@ -12,6 +12,8 @@ use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Support\Links\RequiredPermissionsLinks; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use App\Support\TenantDashboard\TenantDashboardSummaryBuilder; use function Pest\Laravel\mock; @@ -89,6 +91,82 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr ->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard'); }); +it('prioritizes operations requiring attention below permissions and high severity findings and keeps canonical hub links', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + mockTenantDashboardActionPermissions([ + 'overall' => 'blocked', + 'counts' => [ + 'missing_application' => 1, + 'missing_delegated' => 0, + ], + ]); + + Finding::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'created_at' => now()->subMinute(), + 'started_at' => now()->subMinutes(2), + 'completed_at' => now()->subMinute(), + ]); + + $summary = app(TenantDashboardSummaryBuilder::class) + ->build($tenant, $user) + ->toArray(); + + $activeOperationSummary = $summary['activeOperationSummary'] ?? null; + $recommendedActions = $summary['recommendedActions'] ?? []; + + expect($activeOperationSummary) + ->not->toBeNull() + ->and($activeOperationSummary['items'][0]['primaryActionLabel'] ?? null)->toBe('Review operation') + ->and($activeOperationSummary['items'][0]['primaryActionUrl'] ?? null)->toBe(OperationRunLinks::view($run, $tenant)) + ->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub') + ->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)) + ->and(array_column($recommendedActions, 'key'))->toBe([ + 'required_permissions', + 'high_severity_findings', + 'operations_requiring_attention', + ]) + ->and($recommendedActions[2]['title'] ?? null)->toBe('Review operations requiring attention') + ->and($recommendedActions[2]['reason'] ?? null)->toBe('One or more operations finished with an outcome that needs follow-up.') + ->and($recommendedActions[2]['impact'] ?? null)->toBe('The tenant should not be treated as fully healthy until the operation outcome has been reviewed.') + ->and($recommendedActions[2]['actionLabel'] ?? null)->toBe('Review operations') + ->and($recommendedActions[2]['actionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)); +}); + +it('uses review permissions as the top recommended-action CTA when permissions are the highest follow-up', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + mockTenantDashboardActionPermissions([ + 'overall' => 'blocked', + 'counts' => [ + 'missing_application' => 2, + 'missing_delegated' => 0, + ], + ]); + + $recommendedActions = app(TenantDashboardSummaryBuilder::class) + ->build($tenant, $user) + ->toArray()['recommendedActions']; + + expect($recommendedActions[0]['key'] ?? null)->toBe('required_permissions') + ->and($recommendedActions[0]['title'] ?? null)->toBe('Review permissions') + ->and($recommendedActions[0]['actionLabel'] ?? null)->toBe('Review permissions') + ->and($recommendedActions[0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant)); +}); + it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php index 83d89568..adcf14d5 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php @@ -11,6 +11,7 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Support\Auth\Capabilities; +use App\Support\Links\RequiredPermissionsLinks; use App\Support\TenantDashboard\TenantDashboardSummaryBuilder; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -197,6 +198,37 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component) ])); }); +it('derives the primary header CTA from the top recommended action instead of hard-coding operations copy', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + mockTenantDashboardAuthorizationPermissions([ + 'overall' => 'blocked', + 'counts' => [ + 'missing_application' => 1, + 'missing_delegated' => 0, + ], + ]); + + setTenantPanelContext($tenant); + + $summary = app(TenantDashboardSummaryBuilder::class) + ->build($tenant, $user) + ->toArray(); + + $component = Livewire::actingAs($user) + ->test(TenantDashboard::class) + ->assertActionVisible('primaryFollowUp'); + + $primaryAction = collect(tenantDashboardProductizationHeaderActions($component)) + ->first(static fn ($action): bool => $action instanceof Action && $action->getName() === 'primaryFollowUp'); + + expect($summary['recommendedActions'][0]['actionLabel'] ?? null)->toBe('Review permissions') + ->and($summary['recommendedActions'][0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant)) + ->and($primaryAction)->toBeInstanceOf(Action::class) + ->and($primaryAction->getLabel())->toBe('Review permissions') + ->and($primaryAction->getUrl())->toBe(RequiredPermissionsLinks::requiredPermissions($tenant)); +}); + it('renders governance status rows as interactive only when a repo-real follow-up url is available', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php index fc8fb464..130fe9d8 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php @@ -8,6 +8,7 @@ use App\Models\ProviderConnection; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Support\OperationCatalog; +use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\TenantDashboard\TenantDashboardSummaryBuilder; @@ -78,17 +79,20 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->assertSee($tenant->name) ->assertSee('Recommended next actions') ->assertSee('Governance status') + ->assertSee('Operations needing attention') ->assertSee('Current review') ->assertSee('Risk exceptions') ->assertSee('Provider Health') ->assertSee('Customer-safe output') - ->assertSee('Recent operations'); + ->assertSee('Operations requiring attention') + ->assertSee('Review operation') + ->assertSee('Open operations hub') + ->assertDontSee('Recent operations'); $content = $response->getContent(); $contextChipsPosition = strpos($content, 'data-testid="tenant-dashboard-context-chips"'); $firstKpiPosition = strpos($content, 'data-testid="tenant-dashboard-kpi"'); $governanceStatusCount = substr_count($content, 'data-testid="tenant-dashboard-governance-status"'); - $recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"'); $secondaryListRowCount = substr_count($content, 'data-overview-row-style="secondary-list-row"'); expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4) @@ -108,7 +112,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->and($contextChipsPosition)->not->toBeFalse() ->and($firstKpiPosition)->not->toBeFalse() ->and($contextChipsPosition)->toBeLessThan($firstKpiPosition) - ->and($secondaryListRowCount)->toBe($governanceStatusCount + $recentOperationCount) + ->and($secondaryListRowCount)->toBe($governanceStatusCount) ->and($content)->toContain('hover:shadow-md') ->and($content)->toContain('hover:ring-1') ->and(substr_count($content, 'data-kpi-has-icon="true"'))->toBe(4) @@ -116,12 +120,13 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBeLessThanOrEqual(3) ->and(substr_count($content, 'tenant-dashboard-recommended-actions'))->toBeGreaterThanOrEqual(1) ->and(substr_count($content, 'data-testid="tenant-dashboard-governance-status-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-governance-status"')) - ->and(substr_count($content, 'data-testid="tenant-dashboard-recent-operation-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-recent-operation"')) + ->and(substr_count($content, 'data-testid="tenant-dashboard-operations-attention-item-icon"'))->toBeGreaterThanOrEqual(1) ->and(substr_count($content, 'data-testid="tenant-dashboard-readiness-card"'))->toBe(4) ->and($content)->toContain('data-readiness-key="provider_health"') ->and($content)->not->toContain('Open customer workspace') ->and($content)->not->toContain('fixed bottom-4 right-4 z-[999999] w-96 space-y-2') - ->and($content)->toContain('High severity findings'); + ->and($content)->toContain('High severity findings') + ->and($content)->not->toContain('section_recent_operations'); }); it('adds repo-real icon metadata and only supported sparkline series to tenant dashboard kpis', function (): void { @@ -201,6 +206,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void 'missing_permissions', 'active_operations', ]) + ->and($kpis['active_operations']['label'])->toBe('Operations needing attention') ->and($kpis->pluck('icon')->filter()->count())->toBe(4) ->and($kpis['high_severity_findings']['icon'])->toBe('heroicon-m-arrow-trending-up') ->and($kpis['high_severity_findings']['description'])->toBe('4 active · 4 new in 7d') @@ -210,7 +216,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->and($kpis['missing_permissions']['icon'])->toBe('heroicon-m-arrow-trending-up') ->and($kpis['missing_permissions']['description'])->toBe('2 app · 1 delegated missing') ->and($kpis['active_operations']['icon'])->toBe('heroicon-m-arrow-trending-up') - ->and($kpis['active_operations']['description'])->toBe('3 need follow-up · 3 in 7d') + ->and($kpis['active_operations']['description'])->toBe('3 operations require attention') ->and($kpis['active_operations']['chart'])->toBe([0, 1, 0, 0, 2, 0, 0]) ->and($kpis['overdue_findings']['chart'])->toBeNull() ->and($kpis['missing_permissions']['chart'])->toBeNull(); @@ -219,7 +225,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void } }); -it('adds semantic icon metadata to governance status rows and repo-real recent operation types', function (): void { +it('adds semantic icon metadata to governance status rows and curated operations attention items', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); mockTenantDashboardSummaryPermissions(); @@ -229,7 +235,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'inventory_sync', 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, + 'outcome' => OperationRunOutcome::Failed->value, 'created_at' => now()->subMinutes(3), 'completed_at' => now()->subMinutes(3), ]); @@ -239,7 +245,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'tenant.review_pack.generate', 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, + 'outcome' => OperationRunOutcome::Failed->value, 'created_at' => now()->subMinutes(2), 'completed_at' => now()->subMinutes(2), ]); @@ -249,7 +255,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void 'workspace_id' => (int) $tenant->workspace_id, 'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK, 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, + 'outcome' => OperationRunOutcome::Blocked->value, 'created_at' => now()->subMinute(), 'completed_at' => now()->subMinute(), ]); @@ -259,16 +265,16 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void ->toArray(); $governanceStatus = collect($summary['governanceStatus'])->keyBy('key'); - $recentOperations = collect($summary['recentOperations'])->keyBy('type'); + $attentionOperations = collect($summary['activeOperationSummary']['items'] ?? [])->keyBy('type'); expect($governanceStatus['baseline_compare']['icon'] ?? null)->toBe('heroicon-m-arrows-right-left') ->and($governanceStatus['evidence_coverage']['icon'] ?? null)->toBe('heroicon-m-document-check') ->and($governanceStatus['review_freshness']['icon'] ?? null)->toBe('heroicon-m-clipboard-document-check') ->and($governanceStatus['provider_permissions']['icon'] ?? null)->toBe('heroicon-m-key') ->and($governanceStatus['backup_posture']['icon'] ?? null)->toBe('heroicon-m-archive-box') - ->and($recentOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path') - ->and($recentOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down') - ->and($recentOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key'); + ->and($attentionOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path') + ->and($attentionOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down') + ->and($attentionOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key'); }); it('shows calm honest fallbacks when no urgent tenant follow-up is visible', function (): void { @@ -290,14 +296,130 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void $response = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) ->assertSuccessful() ->assertSee('No immediate action is waiting.') - ->assertSee('Recent operations'); + ->assertDontSee('Recent operations') + ->assertDontSee('Operations requiring attention'); $content = $response->getContent(); - $recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"'); - expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-actions-empty"'))->toBe(1) - ->and($recentOperationCount)->toBeGreaterThan(0) - ->and($recentOperationCount)->toBeLessThanOrEqual(4) + ->and($content)->not->toContain('data-testid="tenant-dashboard-operations-attention-summary"') ->and($content)->not->toContain('data-testid="tenant-dashboard-recent-operations-empty"'); }); + +it('builds a curated operations requiring attention summary and excludes healthy active runs', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + mockTenantDashboardSummaryPermissions(); + + $healthyRunningRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinute(), + 'started_at' => now()->subMinute(), + ]); + + $followUpRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'created_at' => now()->subMinutes(6), + 'started_at' => now()->subMinutes(5), + 'completed_at' => now()->subMinutes(4), + ]); + + $blockedRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Blocked->value, + 'created_at' => now()->subMinutes(5), + 'started_at' => now()->subMinutes(4), + 'completed_at' => now()->subMinutes(3), + ]); + + $summary = app(TenantDashboardSummaryBuilder::class) + ->build($tenant, $user) + ->toArray(); + + $activeOperationSummary = $summary['activeOperationSummary'] ?? null; + $items = collect($activeOperationSummary['items'] ?? []); + + expect($activeOperationSummary) + ->not->toBeNull() + ->and($activeOperationSummary['title'] ?? null)->toBe('Operations requiring attention') + ->and($activeOperationSummary['count'] ?? null)->toBe(2) + ->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub') + ->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index( + $tenant, + activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, + problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, + )) + ->and($items)->toHaveCount(2) + ->and($items->pluck('id')->all())->toBe([ + (int) $blockedRun->getKey(), + (int) $followUpRun->getKey(), + ]) + ->and($items->pluck('primaryActionLabel')->unique()->all())->toBe(['Review operation']) + ->and($items->pluck('primaryActionUrl')->all())->toBe([ + OperationRunLinks::view($blockedRun, $tenant), + OperationRunLinks::view($followUpRun, $tenant), + ]) + ->and($items->pluck('attentionLabel')->unique()->all())->toBe(['Follow-up required']) + ->and($items->pluck('timingLabel')->filter()->isNotEmpty())->toBeTrue() + ->and($items->pluck('outcomeSentence')->filter()->isNotEmpty())->toBeTrue() + ->and($items->pluck('reason')->filter()->isNotEmpty())->toBeTrue() + ->and($items->pluck('impact')->filter()->isNotEmpty())->toBeTrue() + ->and($items->pluck('id')->contains((int) $healthyRunningRun->getKey()))->toBeFalse(); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertSuccessful() + ->assertSee('data-testid="tenant-dashboard-operations-attention-summary"', false) + ->assertSee('Review operation') + ->assertSee('Open operations hub') + ->assertSee('Completed '.$followUpRun->completed_at?->diffForHumans()) + ->assertSee('Inventory sync') + ->assertDontSee('Operation #'.$followUpRun->getKey()) + ->assertDontSee('Recent operations'); +}); + +it('omits the compact active operations summary when no qualifying visible run exists', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + mockTenantDashboardSummaryPermissions(); + + OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'created_at' => now()->subMinutes(3), + 'started_at' => now()->subMinutes(2), + 'completed_at' => now()->subMinute(), + ]); + + $summary = app(TenantDashboardSummaryBuilder::class) + ->build($tenant, $user) + ->toArray(); + + expect($summary['activeOperationSummary'] ?? null)->toBeNull(); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertSuccessful() + ->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false) + ->assertDontSee('Review operation') + ->assertDontSee('Open operations hub') + ->assertDontSee('Recent operations'); +}); diff --git a/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php b/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php index 11794fe2..b1227e04 100644 --- a/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php @@ -247,7 +247,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, 'High severity findings', 'Overdue findings', 'Missing permissions', - 'Active operations', + 'Operations needing attention', ]); expect($stats['High severity findings'])->toMatchArray([ @@ -263,7 +263,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant, ], panel: 'tenant', tenant: $tenant)) ->and((int) $stats['Missing permissions']['value'])->toBeGreaterThan(0) ->and($stats['Missing permissions']['url'])->not->toBeNull() - ->and($stats['Active operations'])->toMatchArray([ + ->and($stats['Operations needing attention'])->toMatchArray([ 'value' => '3', 'url' => OperationRunLinks::index( $tenant, diff --git a/apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index f2dfeabf..295f5c67 100644 --- a/apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -4,7 +4,6 @@ use App\Filament\Pages\TenantDashboard; use App\Filament\Widgets\Dashboard\DashboardKpis; -use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations; use App\Filament\Widgets\Dashboard\RecoveryReadiness; use App\Models\BackupItem; use App\Models\BackupSet; @@ -25,7 +24,7 @@ 'status' => Finding::STATUS_NEW, ]); - $operation = OperationRun::factory()->create([ + OperationRun::factory()->create([ 'managed_environment_id' => $tenant->getKey(), 'type' => 'inventory_sync', 'status' => 'queued', @@ -53,26 +52,22 @@ Bus::fake(); Filament::setTenant($tenant, true); - assertNoOutboundHttp(function () use ($operation, $tenant): void { + assertNoOutboundHttp(function () use ($tenant): void { $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee('/admin/choose-workspace', false); - // NeedsAttention, RecentOperations and RecentDriftFindings are - // lazy-loaded widgets and will not appear in the initial - // server-rendered HTML. + ->assertSee('/admin/choose-workspace', false) + ->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false) + ->assertDontSee('Review operation') + ->assertDontSee('Open operations hub') + ->assertDontSee('Recent operations'); Livewire::test(RecoveryReadiness::class) ->assertSee('Backup posture') ->assertSee('Healthy'); Livewire::test(DashboardKpis::class) - ->assertSee('Active operations') - ->assertSee('No follow-up queued'); - - Livewire::test(DashboardRecentOperations::class) - ->assertSee('Operation ID') - ->assertSee('Operation #'.$operation->getKey()) - ->assertSee('Inventory sync'); + ->assertSee('Operations needing attention') + ->assertSee('No operations need attention'); }); Bus::assertNothingDispatched(); diff --git a/specs/273-tenant-dashboard-active-operations-summary-card/checklists/requirements.md b/specs/273-tenant-dashboard-active-operations-summary-card/checklists/requirements.md new file mode 100644 index 00000000..ef5eb08b --- /dev/null +++ b/specs/273-tenant-dashboard-active-operations-summary-card/checklists/requirements.md @@ -0,0 +1,56 @@ +# Specification Quality Checklist: Tenant Dashboard Active Operations Summary Card + +**Purpose**: Validate specification completeness, boundedness, and readiness before implementation +**Created**: 2026-05-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] The package stays on one compact Tenant Dashboard active-operations summary card over existing `OperationRun` truth instead of widening into a full shell banner, dashboard-native operations console, or new widget framework. +- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff. +- [x] The package explicitly names the repo-real anchors it builds on: `TenantDashboardSummaryBuilder`, `TenantDashboardOverview`, `ActiveRuns`, `OperationRunLinks`, `OperationUxPresenter`, existing `OperationRun` dashboard scopes, and the current dashboard Feature and browser proof owners. +- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and manual-promotion rationale are completed. + +## Requirement Completeness + +- [x] No unresolved clarification markers remain. +- [x] Requirements stay testable and bounded to the no-active hidden state, queued or running visible state, stale or follow-up-needed priority, canonical links, visibility or capability gating, and tenant or workspace isolation. +- [x] The package explicitly preserves one dominant `View operation` action plus the neutral canonical `Show all operations` action. +- [x] The package explicitly forbids a full dashboard shell banner, new persistence, new lifecycle ownership, a new widget family, a route or panel family change, provider or asset changes, and raw diagnostics expansion. +- [x] Planned validation commands now match across `spec.md`, `plan.md`, and `tasks.md`. + +## Candidate Selection Gate + +- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is a deliberate manual promotion that stays directionally consistent with the roadmap's dashboard/core-surface guidance after Specs `268` through `272`, even though it is not called out as an explicitly ranked roadmap item. +- [x] The automatic next-best-prep queue is intentionally empty, so this package records itself as a deliberate manual promotion rather than an automatic queue pick. +- [x] Repo verification confirms the current Tenant Dashboard already has the `Active operations` KPI and `Recent operations` history surface but still lacks the dedicated compact active-operations summary card this package defines. +- [x] The chosen slice is smaller and safer than deferred alternatives such as a dashboard shell-banner rollout, a new dashboard operations console, or broader progress redesign work. + +## Feature Readiness + +- [x] The package reuses current `OperationRun` truth and current dashboard summary composition instead of introducing a second lifecycle, a persisted projection, or a dashboard-only query family. +- [x] The package explicitly keeps tenant or workspace isolation and `404` or suppression behavior intact for non-members and actors without `OperationRun` visibility. +- [x] The package forbids new panel, provider, global-search, asset-registration, queue-family, notification-policy, and raw-diagnostics changes. +- [x] The tasks artifact names the likely implementation and proof files already identified in `plan.md`. +- [x] The review artifact, workflow outcome, and test-governance outcome are carried into the active prep package. + +## Test Governance + +- [x] Planned proof stays bounded to focused `Feature` coverage plus one named dashboard `Browser` smoke. +- [x] No new heavy-governance family or broad browser family is introduced by default. +- [x] Fixture growth remains bounded to current tenant dashboard helpers, `OperationRun` factories, tenant context setup, and the existing dashboard browser scaffolding. +- [x] The proving commands stay file-scoped, run through Sail, and keep DB-only visibility or isolation proof explicit instead of widening into unrelated lanes. + +## Notes + +- Reviewed against `specs/273-tenant-dashboard-active-operations-summary-card/spec.md`, `specs/273-tenant-dashboard-active-operations-summary-card/plan.md`, `specs/273-tenant-dashboard-active-operations-summary-card/tasks.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `.specify/memory/constitution.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`, `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, and `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` on 2026-05-07. +- This checklist is the prep-time outcome record. If implementation widens into a full shell banner, a new widget family, new progress semantics, new persistence, route or panel changes, or raw-diagnostics expansion, the workflow outcome must change before merge. +- No application implementation was performed while preparing this package. + +## Review Outcome + +- **Outcome class**: `acceptable-special-case` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Reason**: the automatic queue is intentionally empty, but repo truth still shows one bounded unspecced dashboard seam after Specs `268` through `272`: the Tenant Dashboard already has aggregate and recent-history signals, yet still lacks the compact active-operations summary card this package defines. +- **Workflow result**: Ready for implementation. \ No newline at end of file diff --git a/specs/273-tenant-dashboard-active-operations-summary-card/plan.md b/specs/273-tenant-dashboard-active-operations-summary-card/plan.md new file mode 100644 index 00000000..8a7fe500 --- /dev/null +++ b/specs/273-tenant-dashboard-active-operations-summary-card/plan.md @@ -0,0 +1,100 @@ +# Implementation Plan: Tenant Dashboard Operations Curation & Decision-First UX + +**Branch**: `273-tenant-dashboard-active-operations-summary-card` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md) + +## Summary + +This scope refresh narrows the Tenant Dashboard back to decision-first operations UX. The dashboard keeps one attention KPI, one attention-only recommended action, and one compact `Operations requiring attention` card. It stops rendering recent operations history on the dashboard and pushes detail/history back to the canonical Operations Hub. + +## Implementation Shape + +- Keep all logic inside the existing `TenantDashboardSummaryBuilder` + `TenantDashboardOverview` path. +- Reuse `OperationRun::dashboardNeedsFollowUp()`, `OperationRunLinks`, and `OperationUxPresenter`. +- Reuse canonical Operations Hub filters by dominant real problem class. +- Remove the recent-operations overview section from the tenant dashboard Blade. +- Keep Filament v5 + Livewire v4, no provider-registration changes, no new panels/resources/assets. + +## Affected Surfaces + +- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php` +- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php` +- `apps/platform/lang/en/localization.php` +- `apps/platform/lang/de/localization.php` +- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php` +- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php` +- `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php` +- `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` + +## Design Decisions + +### Query contract + +- Centralize dashboard attention logic in one tenant/workspace-scoped attention query. +- Attention means only `dashboardNeedsFollowUp()` runs. +- Healthy active runs are excluded from the dashboard decision card. + +### KPI contract + +- Keep the KPI slot and chart. +- Replace history-window wording with attention-only decision copy. +- KPI click remains a canonical Operations Hub drill-through. + +### Recommended action contract + +- Promote operations attention above informative readiness states. +- Keep it below missing permissions and high severity findings. +- Use exact review-oriented copy from the scope refresh. + +### Card contract + +- Show at most three attention items. +- Per item: title, outcome sentence, reason, impact, time, `Review operation`. +- Card-level secondary CTA: `Open operations hub`. +- No recent-history list remains on the dashboard. + +## RBAC / Isolation + +- Tenant membership stays the first gate. +- All counts and items remain scoped by `managed_environment_id` and `workspace_id`. +- If the actor cannot access tenant operations, the dashboard card and links stay hidden. + +## Validation + +- Focused tests: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` +- Formatting: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Guardrails + +- Do not add a second operations inbox or history block to the dashboard. +- Do not invent dashboard-only filters or route strings. +- Do not expose raw diagnostics or payload details on the dashboard. +- Do not change provider registration, global search, or destructive-action behavior. + +### Phase 1 - Add one derived active-operations summary payload + +- Extend `TenantDashboardSummaryBuilder` with one compact summary payload that returns count, highlighted run, status/guidance, and canonical navigation actions. +- Reuse `ActiveRuns`, `OperationRun::dashboardNeedsFollowUp()`, `OperationUxPresenter`, and `OperationRunLinks` instead of inventing a new query or presenter layer. +- Keep ranking deterministic: follow-up-needed/stale first, healthy queued/running second, then recency. + +### Phase 2 - Render the card inside the existing overview composition + +- Add the compact card to `tenant-dashboard-overview.blade.php` inside the current main-column composition. +- Use current dashboard card language and Filament primitives, keep one dominant `View operation` action, and keep `Show all operations` neutral. +- Hide the card entirely when no qualifying visible signal exists. + +### Phase 3 - Prove calmness, truth, and tenant-safe visibility + +- Extend focused Feature coverage for visible, hidden, priority, and shared-link behavior. +- Extend or reuse the existing negative tenant dashboard feature proof for non-member or no-OperationRun-visibility suppression. +- Update the existing browser smoke so the real dashboard first screen proves the card appears only when warranted and does not regress the calm layout. + +## Proportionality Review + +- **Current operator problem**: the current Tenant Dashboard can show operations only as aggregate posture or recent history, which forces the operator to infer whether current tenant work needs attention right now. +- **Existing structure is insufficient because**: the `Active operations` KPI is aggregate-only, and `Recent operations` is history-oriented. Neither surface provides one truthful highlighted run plus a dominant next action. +- **Narrowest correct implementation**: one derived summary payload inside the current dashboard summary builder plus one compact card in the existing overview view. +- **Ownership cost created**: a small summary-builder extension, one dashboard render slice, and focused feature/browser proof that must stay aligned with shared OperationRun helpers. +- **Alternative intentionally rejected**: reusing the full shell banner or building a new Operations widget family was rejected because both would make the dashboard louder and broader than the governance-first contract requires. +- **Release truth**: current-release truth. The repo already ships the dashboard, KPI, recent-operation cards, and canonical Operations surfaces; this plan only fills the bounded missing summary seam. diff --git a/specs/273-tenant-dashboard-active-operations-summary-card/spec.md b/specs/273-tenant-dashboard-active-operations-summary-card/spec.md new file mode 100644 index 00000000..85c1a95d --- /dev/null +++ b/specs/273-tenant-dashboard-active-operations-summary-card/spec.md @@ -0,0 +1,305 @@ +# Feature Specification: Tenant Dashboard Operations Curation & Decision-First UX + +**Feature Branch**: `273-tenant-dashboard-active-operations-summary-card` +**Created**: 2026-05-07 +**Status**: Ready for implementation +**Input**: repo-based scope refresh from the original active-operations-summary slice after product review identified dashboard drift: operations had become visible in too many places on the Tenant Dashboard and needed to be reduced to decision-only attention surfaces. + +## Spec Candidate Check + +- **Problem**: the Tenant Dashboard currently exposes operation truth through too many surfaces at once: KPI, recommended action, attention card, and recent-history rendering. That pushes the dashboard toward a second Operations hub instead of a governance-first decision surface. +- **User-visible improvement**: the dashboard keeps only attention-relevant operations. Healthy active or recent historical runs stop taking space on the first screen, while stale or follow-up-needed runs stay visible with one clear next action. +- **Smallest enterprise-capable version**: keep the `Active operations` KPI as an attention KPI, add one review-oriented recommended action when operations need follow-up, render one compact `Operations requiring attention` card with at most 1-3 runs, and remove the recent-operations section from the dashboard overview. +- **Explicit non-goals**: no second operations console, no raw diagnostics on the dashboard, no new persistence, no new OperationRun lifecycle, no new panel/resource/search surface, no route-family changes, and no compatibility shims. +- **Why now**: repo truth already ships canonical Operations list/detail surfaces. The remaining gap is not more operations visibility, but curation: the dashboard must stop duplicating history/detail surfaces and only show decision-relevant execution truth. + +## Scope + +- **Primary route**: `/admin/t/{tenant}` Tenant Dashboard +- **Canonical drill-through routes**: + - `/admin/operations` + - `/admin/operations/{run}` +- **Source of truth**: existing tenant-scoped `operation_runs` only +- **RBAC**: existing tenant membership and operation visibility remain authoritative. Non-members remain `404`, and no card, count, or CTA may leak hidden runs. + +## Shared Pattern Reuse + +- **Cross-cutting feature**: yes +- **Interaction classes**: dashboard KPI, recommended actions, decision card, canonical run navigation +- **Shared contracts reused**: `OperationRunLinks`, `OperationUxPresenter`, `ActiveRuns`, `TenantDashboardSummaryBuilder` +- **Required consistency**: + - Dashboard remains decision-first + - Operations Hub remains diagnostics/history-first + - `OperationRun` remains execution truth + - No local dashboard-only run lifecycle or fake filters + +## UX Contract + +### A. KPI Card + +- Keep the existing `Active operations` KPI slot. +- Count only attention-relevant operations. +- Show one short decision sentence: + - `No operations need attention` + - `1 operation needs follow-up` + - `:count operations require attention` +- KPI click opens the canonical Operations Hub in current tenant context with the dominant real attention filter. + +### B. Recommended Next Actions + +- Show a recommended action only when attention-relevant operations exist. +- Required copy: + - **Title**: `Review operations requiring attention` + - **Reason**: `One or more operations finished with an outcome that needs follow-up.` + - **Impact**: `The tenant should not be treated as fully healthy until the operation outcome has been reviewed.` + - **Primary CTA**: `Review operations` +- Priority rule: + - lower than missing provider permissions + - lower than high severity findings + - higher than purely informative readiness/status items + +### C. Operations Requiring Attention Card + +- Render only when at least one attention-relevant run exists. +- Card title: `Operations requiring attention` +- Show at most 1-3 operations. +- Per operation show: + - clear title + - human-readable outcome sentence + - reason + - impact + - relative time + - primary CTA: `Review operation` +- Card-level secondary CTA: `Open operations hub` +- Healthy queued/running work is excluded from this card. +- Recent-history rendering is not shown on the Tenant Dashboard. + +## Acceptance Criteria + +1. The Tenant Dashboard does not render a Recent Operations section. +2. Healthy queued/running operations do not create a dashboard attention card. +3. Terminal follow-up and stale-active runs do create a dashboard attention signal. +4. The KPI uses attention-only decision copy, not history-window copy. +5. The recommended action appears only when attention-relevant operations exist and uses the required copy. +6. The attention card shows at most 1-3 operations and each operation has `Review operation`. +7. `Open operations hub` and KPI drill-through use canonical `OperationRunLinks` tenant-scoped URLs. +8. The dashboard exposes no raw diagnostics, no execution-history table, and no additional operations overview block. +9. Cross-tenant or hidden runs never affect dashboard counts, items, or links. + +## Test & Validation + +- **Test classification**: Feature + Browser +- **Validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Boundaries + +- No Livewire v3 / Filament v3-v4 patterns. This remains Filament v5 + Livewire v4. +- No provider-registration changes; `apps/platform/bootstrap/providers.php` remains authoritative. +- No global-search impact. +- No destructive actions are introduced. + - non-member / not entitled to workspace scope OR tenant scope -> 404 (deny-as-not-found) + - member but missing capability -> 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe: +- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`, +- which native Filament components or shared UI primitives are used, +- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces, +- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes, +- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows, +- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system, +- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action, +- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling, +- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static, +- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language, and are used to compose product-specific layout rather than a parallel local design system, +- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language. + +**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles, +notifications, audit prose, or related helper copy, the spec MUST describe: +- the target object, +- the operator verb, +- whether source/domain disambiguation is actually needed, +- 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 (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, +- how the dominant next action stays primary and how duplicate visible truth is avoided, +- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why, +- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`), +- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute), +- how workspace and tenant context remain explicit in navigation, action copy, and page semantics, +- and the page contract for each new or materially refactored operator-facing page. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers, +status taxonomies, or other interpretation layers, the spec MUST describe: +- why direct mapping from canonical domain truth to UI is insufficient, +- which existing layer is replaced or why no existing layer can serve, +- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors, +- and how tests focus on business consequences rather than thin indirection alone. + +**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, +the spec MUST include a `UI Action Matrix` and explicitly state whether the Action Surface Contract is satisfied. +The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent, +that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type. +If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. +The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception. + +**Constitution alignment (UX-001 - Layout & Information Architecture):** If this feature adds or modifies any Filament screen, +the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards +(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific +title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions. +If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale. + +### Functional Requirements + +- **FR-001**: The Tenant Dashboard MUST add only one compact active-operations summary surface inside the existing dashboard overview composition. It MUST NOT render the full Spec `268` shell activity banner on the dashboard by default. +- **FR-002**: Card eligibility, count, and highlighted-run truth MUST be derived from existing tenant-scoped OperationRun truth and shared helpers already used for activity or follow-up visibility. The dashboard MUST NOT invent a second lifecycle query model or dashboard-only status taxonomy. +- **FR-003**: When one or more qualifying visible runs are queued, running, stale, or otherwise still need follow-up in the current tenant, the dashboard MUST show one compact summary card that includes a count and one highlighted run. +- **FR-004**: Highlighted-run selection MUST prioritize follow-up-needed or stale attention over healthy queued or running work. When multiple runs share the same attention class, the highlighted run MUST fall back to a deterministic recency rule. +- **FR-005**: Default-visible card content MUST stay limited to concise count text, highlighted run label, one centralized status treatment, one short guidance line, and the canonical `View operation` and `Show all operations` navigation actions. +- **FR-006**: `View operation` MUST open the canonical detail route for the highlighted run through shared link helpers. `Show all operations` MUST open the canonical Operations collection in current tenant context through shared link helpers. Dashboard code MUST NOT hardcode raw route strings. +- **FR-007**: Failed, blocked, stale, or follow-up-needed work MUST receive stronger visual priority than healthy queued or running work. Healthy active work MUST remain a calm secondary signal rather than an alert banner. +- **FR-008**: When more than one qualifying run exists, the card MUST stay compact by showing one highlighted run plus the aggregate count and collection drill-through. It MUST NOT expand into a multi-row operations list, table, or dashboard-native inbox. +- **FR-009**: When no qualifying active or follow-up-needed runs are visible to the current actor, the active-operations summary card MUST remain hidden by default. Recently completed successful runs stay represented through `Recent operations` rather than keeping this card open. +- **FR-010**: The card MUST reuse the dashboard's existing polling cadence and current first-screen density constraints. It MUST NOT add a second poller, floating overlay, or full-width panel that competes with recommended actions, governance status, or readiness cards. +- **FR-011**: The card MUST complement rather than duplicate the existing `Active operations` KPI and `Recent operations` section. The KPI remains aggregate posture, `Recent operations` remains recent history, and the new card remains the current active-or-follow-up summary. +- **FR-012**: The surface MUST stay governance-first and decision-first: one dominant next action, diagnostics-secondary disclosure, no raw implementation detail, and no dashboard-local operations-console affordances. + +### Authorization and Safety Requirements + +- **AR-001**: Existing tenant/admin-plane authorization semantics remain unchanged: non-members or out-of-scope tenant actors remain `404`, while canonical Operations routes continue using current OperationRun authorization for in-scope actors. +- **AR-002**: The card's count, highlighted run, and both navigation actions MUST be limited to runs the current actor can already view through canonical Operations routes. If the actor cannot view OperationRuns for the current tenant, the card MUST stay hidden. +- **AR-003**: No mutating or destructive action is introduced. Both card actions are navigation-only and must not imply acknowledgement, retry, dismissal, or lifecycle mutation. + +### Non-Functional Requirements + +- **NFR-001**: Filament remains v5 on Livewire v4. No panel-provider registration change is allowed, and `bootstrap/providers.php` remains authoritative. +- **NFR-002**: No new panel, globally searchable resource, or asset-registration strategy is introduced. `filament:assets` deployment behavior is unchanged. +- **NFR-003**: The card must use existing dashboard composition patterns and Filament-native primitives or current shared dashboard styles. It must not introduce a new local card, badge, button, or hover language outside the current dashboard family. +- **NFR-004**: Counted, phased, or composite progress semantics remain owned by Specs `270`, `271`, and `272`. This feature may consume current shared status and guidance truth, but it must not invent progress meters, fake percentages, or dashboard-only phase language. +- **NFR-005**: The implementation must preserve current dark-mode correctness, responsive first-screen stability, and current dashboard interaction honesty. Non-interactive areas remain static, and navigation affordances appear only where real routes and permissions exist. + +## Deferred Follow-Ups / Explicit Non-Goals + +- dashboard-native shell-banner redesign or any full-width activity banner on the Tenant Dashboard +- counted-progress dashboard treatment already owned by `270` and `271` +- phase or composite progress treatment on the dashboard already owned by `272` +- a dashboard-native operations inbox, table, or console +- workspace-level or cross-tenant active-operations summary surfaces +- raw diagnostics, logs, payloads, or support-only execution evidence on the dashboard +- broader manual-promotion candidates such as `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1` + +## Key Entities + +- **Active Operations Summary Card**: a derived, non-persisted Tenant Dashboard card that summarizes current active or follow-up-needed OperationRun truth for the current tenant without becoming a second operations console. +- **Highlighted Operation Summary**: the single derived run preview inside the card that determines the dominant next action and keeps `View operation` truthful when multiple qualifying runs exist. +- **Qualifying Dashboard Operation Signal**: an existing tenant-scoped OperationRun that is still active, stale, or otherwise needs follow-up and therefore deserves compact dashboard visibility. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Focused Feature proof shows the Tenant Dashboard rendering one compact active-operations summary card when at least one qualifying visible run exists, with one highlighted run plus canonical `View operation` and `Show all operations` actions. +- **SC-002**: Focused Feature proof shows stale or follow-up-needed work outranking healthy queued or running work whenever both are visible for the current tenant. +- **SC-003**: Focused Feature plus browser proof shows the dashboard remaining calm when no qualifying visible run exists: no summary card, no full shell banner, and no first-screen layout regression. +- **SC-004**: Focused negative visibility proof shows actors without OperationRun visibility receiving no card content, no count leak, and no operation drill-through hints on the dashboard. +- **SC-005**: Covered dashboard scenarios keep the existing `Active operations` KPI and `Recent operations` section present and non-contradictory, with no second OperationRun truth source introduced. + +## Candidate Selection Rationale + +- **Selected candidate**: Tenant Dashboard Active Operations Summary Card +- **Source locations**: + - `docs/product/spec-candidates.md` + - `apps/platform/app/Filament/Pages/TenantDashboard.php` + - `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php` + - `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php` + - `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` + - `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php` + - `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` +- **Why selected**: the automatic next-best-prep queue is intentionally empty, Specs `268` through `272` already exist around OperationRun maturity, and repo exploration confirms the exact gap named by candidate `273`: the Tenant Dashboard currently has recent-operation cards and an `Active operations` KPI but no dedicated active-only summary card. +- **Why this is the smallest viable implementation slice**: it adds one compact dashboard-native summary over existing shared OperationRun truth and canonical links, without reopening shell-banner scope, progress semantics, new persistence, or new widget infrastructure. +- **Why close alternatives were deferred**: + - Specs `269` through `272` are already specced and therefore are not the next unspecced repo-ready prep target + - `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1` remain broader, less bounded manual-promotion items that require larger product decisions than this dashboard follow-up + +## Related-Spec Guardrail Check + +- `specs/268-operationrun-activity-feedback/`: this feature reuses the same run truth family but must not transplant the full shell banner onto the Tenant Dashboard. +- `specs/269-operationrun-terminal-outcome-feedback/`: any follow-up-needed or stale prioritization on the dashboard must stay aligned with the shell's terminal-outcome honesty rather than inventing a dashboard-only urgency model. +- `specs/270-operationrun-progress-contract/`: the dashboard card must consume shared OperationRun truth and must not invent local progress heuristics or fake percentages. +- `specs/271-counted-progress-rollout/`: any richer progress meter remains out of scope here and must flow through the counted-progress contract if it is ever added later. +- `specs/272-operationrun-phase-composite-progress/`: phase/composite progress language remains deferred and must not be smuggled into the dashboard card before the shared progress contract owns it. + +## Assumptions + +- The canonical Operations collection already supports current-tenant drill-through behavior without requiring a new route family for this feature. +- Existing OperationRun helpers are sufficient to support highlighted-run selection and canonical link generation without introducing a new shared presenter layer. +- For v1, the calmest interpretation of the candidate's `hidden or calm empty state` rule is to keep the card hidden when there is no qualifying visible signal. + +## Risks + +- The card can become noisy or redundant if it duplicates the `Active operations` KPI or `Recent operations` instead of keeping one distinct current-summary role. +- A dashboard-local prioritization rule can drift from shared shell or Operations semantics if the implementation stops reusing current shared helpers. +- Permission or tenant-filter mistakes could leak run counts or run existence through the summary card even if the canonical Operations route remains protected. + +## Open Questions + +- None blocking safe implementation. If layout pressure later proves that a persistent empty-state slot is necessary, that should remain a narrow presentation decision and must not widen this slice into a second dashboard operations surface. diff --git a/specs/273-tenant-dashboard-active-operations-summary-card/tasks.md b/specs/273-tenant-dashboard-active-operations-summary-card/tasks.md new file mode 100644 index 00000000..3899e923 --- /dev/null +++ b/specs/273-tenant-dashboard-active-operations-summary-card/tasks.md @@ -0,0 +1,75 @@ +--- +description: "Task list for Tenant Dashboard Operations Curation & Decision-First UX" +--- + +# Tasks: Tenant Dashboard Operations Curation & Decision-First UX + +- [x] T001 Refresh the active local spec scope so the Tenant Dashboard slice is explicitly attention-only and no longer describes a recent-operations surface on the dashboard. +- [x] T002 Update the focused dashboard tests to the new contract: no recent-operations section, attention-only KPI copy, operations attention recommended action, and `Operations requiring attention` card with `Review operation` / `Open operations hub`. +- [x] T003 Re-run the focused dashboard test slice to capture the exact implementation failures against the new contract. +- [x] T004 Refactor `TenantDashboardSummaryBuilder` to centralize a tenant/workspace-scoped attention query and reuse it for KPI counts, recommended action visibility, and the operations attention card. +- [x] T005 Replace the old single highlighted-run payload with a curated 1-3 item attention-card payload that exposes title, outcome sentence, reason, impact, relative time, and canonical review links. +- [x] T006 Update dashboard localization so KPI and recommended-action copy match the new decision-first operations wording in EN and DE. +- [x] T007 Remove the recent-operations section from the tenant dashboard overview Blade and render the new attention-card layout with one card-level hub CTA and per-item review CTAs. +- [x] T008 Keep the dashboard navigation and RBAC contract canonical: no new route strings, no cross-tenant leakage, no destructive actions, and no dashboard-owned operations history surface. +- [x] T009 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`. +- [x] T010 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T011 Review the changed slice against Filament v5 / Livewire v4 guardrails, canonical Operations Hub ownership, and dashboard decision-first UX boundaries. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the compact summary payload plus render slice. +- **Phase 4 (US2)**: depends on US1 because the attention-first priority rule refines the highlighted summary already introduced there. +- **Phase 5 (US3)**: depends on US1 and should land with US2 so the summary stays both truthful and quiet. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and delivers the core compact-summary contract. +- **US2 (P1)**: independently testable after US1 and delivers the follow-up-first attention ordering that makes the summary trustworthy. +- **US3 (P1)**: independently testable after US1 and closes the no-signal or no-visibility calmness contract. + +### Within Each User Story + +- Extend the listed Pest coverage first and make it fail for the intended gap. +- Keep runtime edits inside the current summary builder, current overview view, and current shared OperationRun helpers rather than introducing new dashboard infrastructure. +- Re-run the narrowest affected validation command after each story checkpoint before moving on. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 + US3 together**. The dashboard slice is only truthful once it appears when warranted, prioritizes stale or follow-up-needed work, and disappears when no qualifying or visible signal exists. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 so the compact summary payload and card exist. +3. Deliver US2 so the highlighted run stays attention-first. +4. Deliver US3 so the no-signal and no-visibility behavior stays calm and leak-free. +5. Finish with focused validation and the review-artifact close-out checks. + +### Team Strategy + +1. Settle the proof owner first. +2. Parallelize Feature and browser proof updates while keeping runtime changes local to the current summary builder and overview view. +3. Serialize merges around `TenantDashboardSummaryBuilder.php` and `tenant-dashboard-overview.blade.php` so the compact card contract stays coherent. + +--- + +## Deferred Follow-Ups / Non-Goals + +- full shell activity banner rollout on the Tenant Dashboard +- dashboard-native operations console or a new dashboard widget framework +- counted, phased, or composite progress rollout work already owned by Specs `270` through `272` +- new `OperationRun` lifecycle, queue, or notification-policy changes +- new persistence, cached summary projection, or raw-diagnostics expansion on the dashboard +- route-family, panel, provider, asset, or global-search changes \ No newline at end of file From 023274c46ce7cbe3cd1397bc80b31d383011d51b Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 7 May 2026 19:28:42 +0000 Subject: [PATCH 4/4] feat: normalize provider connection scope contracts (#339) ## Summary - normalize provider-neutral target-scope and identity contracts across provider connection resolution, operation-start gating, verification reporting, and boundary configuration - align provider connection resource, onboarding, tenant summaries, and operation follow-up on the same shared scope contract while keeping Microsoft-specific profile details in provider-owned metadata - add Spec 281 artifacts and focused feature/browser coverage for the new provider-scope contract - move the tenant dashboard context-chip rail into Filament header widgets so the metadata row renders directly under the page subtitle ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - Filament remains on v5 with Livewire v4-compatible surfaces only. - Provider registration location is unchanged; Laravel 11+ providers stay in `apps/platform/bootstrap/providers.php`. - `ProviderConnectionResource` remains non-globally-searchable and still exposes View/Edit pages. - No new asset registration was added; deploy-time `filament:assets` expectations are unchanged. - No new destructive action path was introduced; existing server authorization and confirmation handling remain in place where applicable. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/339 --- .../app/Filament/Pages/TenantDashboard.php | 16 +- .../ManagedTenantOnboardingWizard.php | 15 +- .../Resources/OperationRunResource.php | 15 + .../Resources/ProviderConnectionResource.php | 13 +- .../app/Filament/Resources/TenantResource.php | 12 +- .../Jobs/ProviderComplianceSnapshotJob.php | 14 +- .../Jobs/ProviderConnectionHealthCheckJob.php | 27 +- .../app/Jobs/ProviderInventorySyncJob.php | 14 +- .../Providers/AdminConsentUrlFactory.php | 2 +- .../Services/Providers/CredentialManager.php | 10 +- .../PlatformProviderIdentityResolver.php | 38 +- .../Services/Providers/ProviderGateway.php | 2 +- .../Providers/ProviderIdentityResolution.php | 95 ++-- .../Providers/ProviderIdentityResolver.php | 43 +- .../Providers/ProviderOperationStartGate.php | 83 +++- .../Verification/StartVerification.php | 4 +- .../ProviderConnectionSurfaceSummary.php | 23 +- ...roviderConnectionTargetScopeNormalizer.php | 26 +- .../BlockedVerificationReportFactory.php | 62 ++- .../StaleQueuedVerificationReportFactory.php | 58 ++- .../VerificationReportSanitizer.php | 38 +- apps/platform/config/provider_boundaries.php | 19 +- .../provider-connection-state.blade.php | 9 +- .../tenant-dashboard-context-chips.blade.php | 8 +- ...TenantDashboardProductizationSmokeTest.php | 2 + ...pec281ProviderConnectionScopeSmokeTest.php | 86 ++++ .../ProviderConnectionIdentityAuditTest.php | 7 +- ...nantDashboardProductizationSummaryTest.php | 5 +- ...iderConnectionResourceScopeSummaryTest.php | 53 +++ ...rConnectionMicrosoftScopeLeakGuardTest.php | 36 ++ ...tOnboardingProviderConnectionScopeTest.php | 60 +++ .../ProviderComplianceSnapshotJobTest.php | 14 +- .../ProviderConnectionHealthCheckJobTest.php | 15 +- ...rConnectionHealthCheckStartSurfaceTest.php | 11 +- .../ProviderOperationConcurrencyTest.php | 33 +- ...derConnectionTargetScopeNeutralityTest.php | 57 +++ ...oviderIdentityResolutionNeutralityTest.php | 66 +++ ...erationStartGateTargetScopeContextTest.php | 93 ++++ .../PlatformProviderIdentityResolverTest.php | 3 +- .../ProviderBoundaryClassificationTest.php | 6 +- ...oviderIdentityResolutionNeutralityTest.php | 5 +- .../ProviderIdentityResolverTest.php | 3 +- .../ProviderOperationStartGateTest.php | 27 +- .../checklists/requirements.md | 68 +++ ...ider-connection-scope.logical.openapi.yaml | 438 ++++++++++++++++++ .../data-model.md | 232 ++++++++++ specs/281-provider-connection-scope/plan.md | 288 ++++++++++++ .../quickstart.md | 41 ++ .../281-provider-connection-scope/research.md | 97 ++++ specs/281-provider-connection-scope/spec.md | 403 ++++++++++++++++ specs/281-provider-connection-scope/tasks.md | 214 +++++++++ 51 files changed, 2804 insertions(+), 205 deletions(-) create mode 100644 apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php create mode 100644 apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php create mode 100644 apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php create mode 100644 apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php create mode 100644 specs/281-provider-connection-scope/checklists/requirements.md create mode 100644 specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml create mode 100644 specs/281-provider-connection-scope/data-model.md create mode 100644 specs/281-provider-connection-scope/plan.md create mode 100644 specs/281-provider-connection-scope/quickstart.md create mode 100644 specs/281-provider-connection-scope/research.md create mode 100644 specs/281-provider-connection-scope/spec.md create mode 100644 specs/281-provider-connection-scope/tasks.md diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index f7acdcba..68c600e2 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -93,6 +93,21 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ? return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters); } + /** + * @return array | WidgetConfiguration> + */ + protected function getHeaderWidgets(): array + { + return [ + TenantDashboardContextChips::class, + ]; + } + + public function getHeaderWidgetsColumns(): int|array + { + return 1; + } + /** * @return array | WidgetConfiguration> */ @@ -100,7 +115,6 @@ public function getWidgets(): array { return [ TenantTriageArrivalContinuity::class, - TenantDashboardContextChips::class, DashboardKpis::class, TenantDashboardOverview::class, ]; diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 081f3871..1a3453ca 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -1473,16 +1473,16 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr 'verification_state' => $this->stringValue($connection->verification_status), 'readiness_summary' => 'Target scope needs review', 'target_scope_summary' => 'Target scope needs review', + 'provider_context' => [ + 'provider' => (string) $connection->provider, + 'details' => [], + ], 'contextual_identity_line' => null, 'is_enabled' => (bool) $connection->is_enabled, ]; } - return array_merge($summary->toArray(), [ - 'target_scope_summary' => $summary->targetScopeSummary(), - 'contextual_identity_line' => $summary->contextualIdentityLine(), - 'is_enabled' => (bool) $connection->is_enabled, - ]); + return $summary->toArray(); } /** @@ -2658,7 +2658,10 @@ private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $ 'shared_label' => 'Target scope', 'shared_help_text' => 'The platform scope this provider connection represents.', ], - 'provider_identity_context' => [], + 'provider_context' => [ + 'provider' => (string) $connection->provider, + 'details' => [], + ], ], $extra); } } diff --git a/apps/platform/app/Filament/Resources/OperationRunResource.php b/apps/platform/app/Filament/Resources/OperationRunResource.php index d46737dd..99883749 100644 --- a/apps/platform/app/Filament/Resources/OperationRunResource.php +++ b/apps/platform/app/Filament/Resources/OperationRunResource.php @@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string return null; } + $scopeDisplayName = $targetScope['scope_display_name'] ?? null; + $scopeIdentifier = $targetScope['scope_identifier'] ?? null; + $scopeDisplayName = is_string($scopeDisplayName) ? trim($scopeDisplayName) : null; + $scopeIdentifier = is_string($scopeIdentifier) ? trim($scopeIdentifier) : null; + + if ($scopeDisplayName !== null && $scopeDisplayName !== '') { + return $scopeIdentifier !== null && $scopeIdentifier !== '' && $scopeIdentifier !== $scopeDisplayName + ? "{$scopeDisplayName} ({$scopeIdentifier})" + : $scopeDisplayName; + } + + if ($scopeIdentifier !== null && $scopeIdentifier !== '') { + return $scopeIdentifier; + } + $entraTenantName = $targetScope['entra_tenant_name'] ?? null; $entraTenantId = $targetScope['entra_tenant_id'] ?? null; $directoryContextId = $targetScope['directory_context_id'] ?? null; diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index 67bf2588..dfa0ef5d 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -506,7 +506,7 @@ private static function targetScopeSummary(?ProviderConnection $record): string } } - private static function providerIdentityContext(?ProviderConnection $record): ?string + private static function providerContextSummary(?ProviderConnection $record): ?string { if (! $record instanceof ProviderConnection) { return null; @@ -539,7 +539,10 @@ public static function targetScopeAuditMetadata(ProviderConnection $record, arra 'shared_label' => 'Target scope', 'shared_help_text' => static::targetScopeHelpText(), ], - 'provider_identity_context' => [], + 'provider_context' => [ + 'provider' => (string) $record->provider, + 'details' => [], + ], ], $extra); } } @@ -681,9 +684,9 @@ public static function infolist(Schema $schema): Schema ->label('Migration review') ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), - Infolists\Components\TextEntry::make('provider_identity_context') - ->label('Provider identity details') - ->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record)) + Infolists\Components\TextEntry::make('provider_context') + ->label('Provider context') + ->state(fn (ProviderConnection $record): ?string => static::providerContextSummary($record)) ->placeholder('n/a') ->columnSpanFull(), Infolists\Components\TextEntry::make('last_error_reason_code') diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index 77236daa..f322c5fa 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -62,6 +62,7 @@ use App\Support\Tenants\TenantOperabilityOutcome; use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantRecoveryTriagePresentation; +use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -3361,7 +3362,8 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string * consent_status:?string, * verification_status:?string, * last_health_check_at:?string, - * last_error_reason_code:?string + * last_error_reason_code:?string, + * target_scope_summary:?string * } */ private static function providerConnectionState(ManagedEnvironment $tenant): array @@ -3396,9 +3398,16 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr 'verification_status' => null, 'last_health_check_at' => null, 'last_error_reason_code' => null, + 'target_scope_summary' => null, ]; } + try { + $targetScopeSummary = ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary(); + } catch (\InvalidArgumentException) { + $targetScopeSummary = 'Target scope needs review'; + } + return [ 'state' => $connection->is_default ? 'default_configured' : 'configured', 'cta_url' => $ctaUrl, @@ -3415,6 +3424,7 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr : (is_string($connection->verification_status) ? $connection->verification_status : null), 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null, + 'target_scope_summary' => $targetScopeSummary, ]; } diff --git a/apps/platform/app/Jobs/ProviderComplianceSnapshotJob.php b/apps/platform/app/Jobs/ProviderComplianceSnapshotJob.php index 13fd34ea..ae58f824 100644 --- a/apps/platform/app/Jobs/ProviderComplianceSnapshotJob.php +++ b/apps/platform/app/Jobs/ProviderComplianceSnapshotJob.php @@ -14,6 +14,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\RunFailureSanitizer; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void { $context = is_array($run->context) ? $run->context : []; - $targetScope = $context['target_scope'] ?? []; - $targetScope = is_array($targetScope) ? $targetScope : []; - - $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + $targetScope = $normalizer->descriptorForConnection($connection)->toArray(); if (is_string($entraTenantName) && $entraTenantName !== '') { - $targetScope['entra_tenant_name'] = $entraTenantName; + $targetScope['scope_display_name'] = $entraTenantName; } + $context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type; $context['target_scope'] = $targetScope; + $context['provider_context'] = $normalizer->providerContext( + provider: (string) $connection->provider, + details: $normalizer->contextualIdentityDetailsForConnection($connection), + ); $run->update(['context' => $context]); } diff --git a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php index afd0e9d0..c0056a37 100644 --- a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -23,6 +23,7 @@ use App\Support\OperationRunStatus; use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\VerificationReportWriter; use Illuminate\Bus\Queueable; @@ -178,6 +179,9 @@ public function handle( } $permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory); + $targetScope = app(ProviderConnectionTargetScopeNormalizer::class) + ->descriptorForConnection($connection) + ->toArray(); $report = VerificationReportWriter::write( run: $this->operationRun, @@ -196,8 +200,8 @@ public function handle( 'value' => (int) $connection->getKey(), ], [ - 'kind' => 'entra_tenant_id', - 'value' => (string) $connection->entra_tenant_id, + 'kind' => 'target_scope_identifier', + 'value' => (string) ($targetScope['scope_identifier'] ?? $connection->entra_tenant_id), ], is_numeric($result->meta['http_status'] ?? null) ? [ 'kind' => 'http_status', @@ -224,7 +228,7 @@ public function handle( ], identity: [ 'provider_connection_id' => (int) $connection->getKey(), - 'entra_tenant_id' => (string) $connection->entra_tenant_id, + 'target_scope' => $targetScope, ], ); @@ -360,17 +364,20 @@ private function resolveEntraTenantName(ProviderConnection $connection, HealthRe private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void { $context = is_array($run->context) ? $run->context : []; - $targetScope = $context['target_scope'] ?? []; - $targetScope = is_array($targetScope) ? $targetScope : []; + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + $targetScope = $normalizer->descriptorForConnection($connection)->toArray(); - $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; - $targetScope['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type; + $context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type; if (is_string($entraTenantName) && $entraTenantName !== '') { - $targetScope['entra_tenant_name'] = $entraTenantName; + $targetScope['scope_display_name'] = $entraTenantName; } $context['target_scope'] = $targetScope; + $context['provider_context'] = $normalizer->providerContext( + provider: (string) $connection->provider, + details: $normalizer->contextualIdentityDetailsForConnection($connection), + ); $run->update(['context' => $context]); } @@ -453,9 +460,9 @@ private function logVerificationResult( 'credential_source' => $identity->credentialSource, 'effective_client_id' => $identity->effectiveClientId, 'target_scope' => $identity->targetScope?->toArray(), - 'provider_identity_context' => array_map( + 'provider_context' => array_map( static fn ($detail): array => $detail->toArray(), - $identity->contextualIdentityDetails, + $identity->providerContextDetails, ), 'reason_code' => $reasonCode, 'operation_run_id' => (int) $run->getKey(), diff --git a/apps/platform/app/Jobs/ProviderInventorySyncJob.php b/apps/platform/app/Jobs/ProviderInventorySyncJob.php index a0b92140..e45aaba3 100644 --- a/apps/platform/app/Jobs/ProviderInventorySyncJob.php +++ b/apps/platform/app/Jobs/ProviderInventorySyncJob.php @@ -14,6 +14,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\RunFailureSanitizer; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void { $context = is_array($run->context) ? $run->context : []; - $targetScope = $context['target_scope'] ?? []; - $targetScope = is_array($targetScope) ? $targetScope : []; - - $targetScope['entra_tenant_id'] = $connection->entra_tenant_id; + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + $targetScope = $normalizer->descriptorForConnection($connection)->toArray(); if (is_string($entraTenantName) && $entraTenantName !== '') { - $targetScope['entra_tenant_name'] = $entraTenantName; + $targetScope['scope_display_name'] = $entraTenantName; } + $context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type; $context['target_scope'] = $targetScope; + $context['provider_context'] = $normalizer->providerContext( + provider: (string) $connection->provider, + details: $normalizer->contextualIdentityDetailsForConnection($connection), + ); $run->update(['context' => $context]); } diff --git a/apps/platform/app/Services/Providers/AdminConsentUrlFactory.php b/apps/platform/app/Services/Providers/AdminConsentUrlFactory.php index b02c57f8..f2735f1d 100644 --- a/apps/platform/app/Services/Providers/AdminConsentUrlFactory.php +++ b/apps/platform/app/Services/Providers/AdminConsentUrlFactory.php @@ -25,7 +25,7 @@ public function make(ProviderConnection $connection, string $state): string throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.'); } - $tenantSegment = trim($resolution->tenantContext) !== '' ? trim($resolution->tenantContext) : 'organizations'; + $tenantSegment = $resolution->targetScopeIdentifier('organizations') ?? 'organizations'; return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([ 'client_id' => $resolution->effectiveClientId, diff --git a/apps/platform/app/Services/Providers/CredentialManager.php b/apps/platform/app/Services/Providers/CredentialManager.php index b12b61e2..856c444e 100644 --- a/apps/platform/app/Services/Providers/CredentialManager.php +++ b/apps/platform/app/Services/Providers/CredentialManager.php @@ -44,10 +44,14 @@ public function getClientCredentials(ProviderConnection $connection): array throw new RuntimeException('Provider credential payload is missing required keys.'); } - $tenantId = $payload['managed_environment_id'] ?? null; + $targetScopeIdentifier = $payload['managed_environment_id'] ?? null; - if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) { - throw new InvalidArgumentException('Provider credential managed_environment_id does not match the connection entra_tenant_id.'); + if ( + is_string($targetScopeIdentifier) + && $targetScopeIdentifier !== '' + && $targetScopeIdentifier !== $connection->entra_tenant_id + ) { + throw new InvalidArgumentException('Provider credential target scope does not match the connection target scope.'); } return [ diff --git a/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php b/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php index ec1916ef..88e2edb0 100644 --- a/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php +++ b/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php @@ -10,70 +10,70 @@ final class PlatformProviderIdentityResolver { /** - * @param list $contextualIdentityDetails + * @param list $providerContextDetails */ public function resolve( - string $tenantContext, + string $targetScopeIdentifier, ?ProviderConnectionTargetScopeDescriptor $targetScope = null, - array $contextualIdentityDetails = [], + array $providerContextDetails = [], ): ProviderIdentityResolution { - $targetTenant = trim($tenantContext); + $targetScopeIdentifier = trim($targetScopeIdentifier); $clientId = trim((string) config('graph.client_id')); $clientSecret = trim((string) config('graph.client_secret')); $authorityTenant = trim((string) config('graph.managed_environment_id', 'organizations')); $redirectUri = trim((string) route('admin.consent.callback')); - if ($targetTenant === '') { + if ($targetScopeIdentifier === '') { return ProviderIdentityResolution::blocked( connectionType: ProviderConnectionType::Platform, - tenantContext: 'organizations', credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, message: 'Provider connection is missing target tenant scope.', targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } + $targetScope ??= ProviderConnectionTargetScopeDescriptor::fromInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: $targetScopeIdentifier, + ); + if ($clientId === '') { return ProviderIdentityResolution::blocked( connectionType: ProviderConnectionType::Platform, - tenantContext: $targetTenant, credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::PlatformIdentityMissing, message: 'Platform app identity is not configured.', targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } if ($clientSecret === '' || $redirectUri === '') { return ProviderIdentityResolution::blocked( connectionType: ProviderConnectionType::Platform, - tenantContext: $targetTenant, credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete, message: 'Platform app identity is incomplete.', targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } return ProviderIdentityResolution::resolved( connectionType: ProviderConnectionType::Platform, - tenantContext: $targetTenant, + targetScope: $targetScope, effectiveClientId: $clientId, credentialSource: 'platform_config', clientSecret: $clientSecret, authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations', redirectUri: $redirectUri, - targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails !== [] - ? array_values(array_merge($contextualIdentityDetails, array_filter([ - ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'), - ProviderIdentityContextMetadata::redirectUri($redirectUri), - ]))) - : [], + providerContextDetails: array_values(array_merge($providerContextDetails, array_filter([ + ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'), + ProviderIdentityContextMetadata::redirectUri($redirectUri), + ]))), ); } } diff --git a/apps/platform/app/Services/Providers/ProviderGateway.php b/apps/platform/app/Services/Providers/ProviderGateway.php index ecf03840..7b2cb174 100644 --- a/apps/platform/app/Services/Providers/ProviderGateway.php +++ b/apps/platform/app/Services/Providers/ProviderGateway.php @@ -62,7 +62,7 @@ public function graphOptions(ProviderConnection $connection, array $overrides = } return array_merge([ - 'tenant' => $resolution->tenantContext, + 'tenant' => $resolution->targetScopeIdentifier('organizations'), 'client_id' => $resolution->effectiveClientId, 'client_secret' => $resolution->clientSecret, 'client_request_id' => (string) Str::uuid(), diff --git a/apps/platform/app/Services/Providers/ProviderIdentityResolution.php b/apps/platform/app/Services/Providers/ProviderIdentityResolution.php index 77a2475d..75fbcf71 100644 --- a/apps/platform/app/Services/Providers/ProviderIdentityResolution.php +++ b/apps/platform/app/Services/Providers/ProviderIdentityResolution.php @@ -10,12 +10,12 @@ final class ProviderIdentityResolution { /** - * @param list $contextualIdentityDetails + * @param list $providerContextDetails */ private function __construct( public readonly bool $resolved, public readonly ProviderConnectionType $connectionType, - public readonly string $tenantContext, + public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope, public readonly ?string $effectiveClientId, public readonly string $credentialSource, public readonly ?string $clientSecret, @@ -23,25 +23,26 @@ private function __construct( public readonly ?string $redirectUri, public readonly ?string $reasonCode, public readonly ?string $message, - public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope, - public readonly array $contextualIdentityDetails, + public readonly array $providerContextDetails, ) {} + /** + * @param list $providerContextDetails + */ public static function resolved( ProviderConnectionType $connectionType, - string $tenantContext, + ProviderConnectionTargetScopeDescriptor $targetScope, string $effectiveClientId, string $credentialSource, ?string $clientSecret, ?string $authorityTenant, ?string $redirectUri, - ?ProviderConnectionTargetScopeDescriptor $targetScope = null, - array $contextualIdentityDetails = [], + array $providerContextDetails = [], ): self { return new self( resolved: true, connectionType: $connectionType, - tenantContext: $tenantContext, + targetScope: $targetScope, effectiveClientId: $effectiveClientId, credentialSource: $credentialSource, clientSecret: $clientSecret, @@ -49,26 +50,25 @@ public static function resolved( redirectUri: $redirectUri, reasonCode: null, message: null, - targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext), - contextualIdentityDetails: $contextualIdentityDetails !== [] - ? $contextualIdentityDetails - : self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri), + providerContextDetails: $providerContextDetails, ); } + /** + * @param list $providerContextDetails + */ public static function blocked( ProviderConnectionType $connectionType, - string $tenantContext, string $credentialSource, string $reasonCode, ?string $message = null, ?ProviderConnectionTargetScopeDescriptor $targetScope = null, - array $contextualIdentityDetails = [], + array $providerContextDetails = [], ): self { return new self( resolved: false, connectionType: $connectionType, - tenantContext: $tenantContext, + targetScope: $targetScope, effectiveClientId: null, credentialSource: $credentialSource, clientSecret: null, @@ -76,10 +76,7 @@ public static function blocked( redirectUri: null, reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError, message: $message, - targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null), - contextualIdentityDetails: $contextualIdentityDetails !== [] - ? $contextualIdentityDetails - : self::contextualIdentityDetails($tenantContext), + providerContextDetails: $providerContextDetails, ); } @@ -88,35 +85,51 @@ public function effectiveReasonCode(): string return $this->reasonCode ?? ProviderReasonCodes::UnknownError; } - private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor + public function targetScopeIdentifier(?string $fallback = null): ?string { - $identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations'; + $identifier = trim((string) $this->targetScope?->scopeIdentifier); - return ProviderConnectionTargetScopeDescriptor::fromInput( - provider: 'microsoft', - scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, - scopeIdentifier: $identifier, - scopeDisplayName: $identifier, - ); + if ($identifier !== '') { + return $identifier; + } + + return $fallback; } /** - * @return list + * @return array{client_id: ?string, credential_source: string} */ - private static function contextualIdentityDetails( - string $tenantContext, - ?string $authorityTenant = null, - ?string $redirectUri = null, - ): array { - $details = [ - ProviderIdentityContextMetadata::microsoftTenantId($tenantContext), - ProviderIdentityContextMetadata::authorityTenant($authorityTenant), - ProviderIdentityContextMetadata::redirectUri($redirectUri), + public function effectiveClientIdentity(): array + { + return [ + 'client_id' => $this->effectiveClientId, + 'credential_source' => $this->credentialSource, ]; + } - return array_values(array_filter( - $details, - static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata, - )); + /** + * @return array{provider: string, details: list>} + */ + public function providerContext(): array + { + $provider = $this->targetScope?->provider; + + if (! is_string($provider) || trim($provider) === '') { + foreach ($this->providerContextDetails as $detail) { + if (trim($detail->provider) !== '') { + $provider = $detail->provider; + + break; + } + } + } + + return [ + 'provider' => is_string($provider) && trim($provider) !== '' ? trim($provider) : 'unknown', + 'details' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $this->providerContextDetails, + ), + ]; } } diff --git a/apps/platform/app/Services/Providers/ProviderIdentityResolver.php b/apps/platform/app/Services/Providers/ProviderIdentityResolver.php index a43dec4f..a28f919e 100644 --- a/apps/platform/app/Services/Providers/ProviderIdentityResolver.php +++ b/apps/platform/app/Services/Providers/ProviderIdentityResolver.php @@ -22,61 +22,58 @@ public function __construct( public function resolve(ProviderConnection $connection): ProviderIdentityResolution { - $tenantContext = trim((string) $connection->entra_tenant_id); + $targetScopeIdentifier = trim((string) $connection->entra_tenant_id); $connectionType = $this->resolveConnectionType($connection); $targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection); $targetScope = $targetScopeResult['target_scope'] ?? null; - $contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection); + $providerContextDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection); if ($connectionType === null) { return ProviderIdentityResolution::blocked( connectionType: ProviderConnectionType::Platform, - tenantContext: $tenantContext !== '' ? $tenantContext : 'organizations', credentialSource: 'unknown', reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid, message: 'Provider connection type is invalid.', targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { return ProviderIdentityResolution::blocked( connectionType: $connectionType, - tenantContext: 'organizations', credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value, reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.', targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } if ((bool) $connection->migration_review_required) { return ProviderIdentityResolution::blocked( connectionType: $connectionType, - tenantContext: $tenantContext, credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value, reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired, message: 'Provider connection requires migration review before use.', targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } if ($connectionType === ProviderConnectionType::Platform) { return $this->platformResolver->resolve( - tenantContext: $tenantContext, + targetScopeIdentifier: $targetScopeIdentifier, targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } return $this->resolveDedicatedIdentity( connection: $connection, - tenantContext: $tenantContext, + targetScopeIdentifier: $targetScopeIdentifier, targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } @@ -97,36 +94,42 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide private function resolveDedicatedIdentity( ProviderConnection $connection, - string $tenantContext, + string $targetScopeIdentifier, ?ProviderConnectionTargetScopeDescriptor $targetScope = null, - array $contextualIdentityDetails = [], + array $providerContextDetails = [], ): ProviderIdentityResolution { try { $credentials = $this->credentials->getClientCredentials($connection); } catch (InvalidArgumentException|RuntimeException $exception) { return ProviderIdentityResolution::blocked( connectionType: ProviderConnectionType::Dedicated, - tenantContext: $tenantContext, credentialSource: $this->credentialSource($connection), reasonCode: $exception instanceof InvalidArgumentException ? ProviderReasonCodes::DedicatedCredentialInvalid : ProviderReasonCodes::DedicatedCredentialMissing, message: $exception->getMessage(), targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, + ); + } + + if (! $targetScope instanceof ProviderConnectionTargetScopeDescriptor) { + $targetScope = ProviderConnectionTargetScopeDescriptor::fromInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: $targetScopeIdentifier !== '' ? $targetScopeIdentifier : 'organizations', ); } return ProviderIdentityResolution::resolved( connectionType: ProviderConnectionType::Dedicated, - tenantContext: $tenantContext, + targetScope: $targetScope, effectiveClientId: $credentials['client_id'], credentialSource: $this->credentialSource($connection), clientSecret: $credentials['client_secret'], - authorityTenant: $tenantContext, + authorityTenant: $targetScope->scopeIdentifier, redirectUri: trim((string) route('admin.consent.callback')), - targetScope: $targetScope, - contextualIdentityDetails: $contextualIdentityDetails, + providerContextDetails: $providerContextDetails, ); } diff --git a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php index 45725662..473f2a08 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php +++ b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php @@ -12,6 +12,9 @@ use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; +use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata; use App\Support\Verification\BlockedVerificationReportFactory; use App\Support\Verification\StaleQueuedVerificationReportFactory; use App\Support\Verification\VerificationReportWriter; @@ -28,6 +31,7 @@ public function __construct( private readonly ProviderConnectionResolver $resolver, private readonly ProviderNextStepsRegistry $nextStepsRegistry, private readonly OperationRunCapabilityResolver $capabilityResolver, + private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer, ) {} /** @@ -137,9 +141,9 @@ public function start( 'module' => $definition['module'], 'provider_binding' => $this->bindingContext($binding), 'provider_connection_id' => (int) $lockedConnection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $lockedConnection->entra_tenant_id, - ], + 'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type, + 'target_scope' => $this->targetScopeContextForConnection($lockedConnection), + 'provider_context' => $this->providerContextForConnection($lockedConnection), ]); $run = $this->runs->ensureRunWithIdentity( @@ -185,9 +189,9 @@ private function startBlocked( 'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext), 'provider' => $provider, 'module' => $module, - 'target_scope' => [ - 'entra_tenant_id' => $tenant->graphTenantId(), - ], + 'target_scope' => $connection instanceof ProviderConnection + ? $this->targetScopeContextForConnection($connection) + : $this->targetScopeContextForTenant($tenant, $provider), ]); $identityInputs = [ @@ -202,6 +206,8 @@ private function startBlocked( if ($connection instanceof ProviderConnection) { $context['provider_connection_id'] = (int) $connection->getKey(); + $context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type; + $context['provider_context'] = $this->providerContextForConnection($connection); $identityInputs['provider_connection_id'] = (int) $connection->getKey(); } @@ -287,6 +293,71 @@ private function bindingContext(array $binding): array ]; } + /** + * @return array{ + * provider: string, + * scope_kind: string, + * scope_identifier: string, + * scope_display_name: string, + * shared_label: string, + * shared_help_text: string + * } + */ + private function targetScopeContextForConnection(ProviderConnection $connection): array + { + try { + return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray(); + } catch (InvalidArgumentException) { + $identifier = trim((string) $connection->entra_tenant_id); + $fallbackIdentifier = $connection->tenant instanceof ManagedEnvironment + ? trim((string) $connection->tenant->graphTenantId()) + : ''; + + return ProviderConnectionTargetScopeDescriptor::fromInput( + provider: (string) $connection->provider, + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: $identifier !== '' ? $identifier : ($fallbackIdentifier !== '' ? $fallbackIdentifier : (string) $connection->getKey()), + scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier), + )->toArray(); + } + } + + /** + * @return array{ + * provider: string, + * scope_kind: string, + * scope_identifier: string, + * scope_display_name: string, + * shared_label: string, + * shared_help_text: string + * } + */ + private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array + { + $identifier = trim($tenant->providerTenantContext()); + + return ProviderConnectionTargetScopeDescriptor::fromInput( + provider: $provider !== '' ? $provider : 'unknown', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: $identifier, + scopeDisplayName: (string) ($tenant->name ?? $identifier), + )->toArray(); + } + + /** + * @return array{provider: string, details: list>} + */ + private function providerContextForConnection(ProviderConnection $connection): array + { + return [ + 'provider' => (string) $connection->provider, + 'details' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection), + ), + ]; + } + /** * @param array $extraContext */ diff --git a/apps/platform/app/Services/Verification/StartVerification.php b/apps/platform/app/Services/Verification/StartVerification.php index 5da9961d..171b82c8 100644 --- a/apps/platform/app/Services/Verification/StartVerification.php +++ b/apps/platform/app/Services/Verification/StartVerification.php @@ -120,9 +120,9 @@ public function providerConnectionCheckUsingConnection( 'credential_source' => $identity->credentialSource, 'effective_client_id' => $identity->effectiveClientId, 'target_scope' => $identity->targetScope?->toArray(), - 'provider_identity_context' => array_map( + 'provider_context' => array_map( static fn ($detail): array => $detail->toArray(), - $identity->contextualIdentityDetails, + $identity->providerContextDetails, ), ], ]), diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php index 12cb468c..4db984d3 100644 --- a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php @@ -20,6 +20,7 @@ public function __construct( public readonly string $verificationState, public readonly string $readinessSummary, public readonly array $contextualIdentityDetails = [], + public readonly bool $isEnabled = true, ) {} public static function forConnection(ProviderConnection $connection): self @@ -41,6 +42,7 @@ public static function forConnection(ProviderConnection $connection): self verificationState: $verificationState, ), contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection), + isEnabled: (bool) $connection->is_enabled, ); } @@ -67,7 +69,10 @@ public function contextualIdentityLine(): ?string * consent_state: string, * verification_state: string, * readiness_summary: string, - * contextual_identity_details: list> + * target_scope_summary: string, + * provider_context: array{provider: string, details: list>}, + * contextual_identity_line: ?string, + * is_enabled: bool * } */ public function toArray(): array @@ -78,7 +83,21 @@ public function toArray(): array 'consent_state' => $this->consentState, 'verification_state' => $this->verificationState, 'readiness_summary' => $this->readinessSummary, - 'contextual_identity_details' => array_map( + 'target_scope_summary' => $this->targetScopeSummary(), + 'provider_context' => $this->providerContext(), + 'contextual_identity_line' => $this->contextualIdentityLine(), + 'is_enabled' => $this->isEnabled, + ]; + } + + /** + * @return array{provider: string, details: list>} + */ + public function providerContext(): array + { + return [ + 'provider' => $this->provider, + 'details' => array_map( static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), $this->contextualIdentityDetails, ), diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php index ba27a727..0ff73e85 100644 --- a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php @@ -144,13 +144,31 @@ public function auditMetadataForConnection(ProviderConnection $connection, array 'provider_connection_id' => (int) $connection->getKey(), 'provider' => (string) $connection->provider, 'target_scope' => $summary->targetScope->toArray(), - 'provider_identity_context' => array_map( - static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), - $summary->contextualIdentityDetails, - ), + 'provider_context' => [ + 'provider' => (string) $connection->provider, + 'details' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $summary->contextualIdentityDetails, + ), + ], ], $extra); } + /** + * @param list $details + * @return array{provider: string, details: list>} + */ + public function providerContext(string $provider, array $details): array + { + return [ + 'provider' => $provider, + 'details' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $details, + ), + ]; + } + /** * @param list $fields * @return list diff --git a/apps/platform/app/Support/Verification/BlockedVerificationReportFactory.php b/apps/platform/app/Support/Verification/BlockedVerificationReportFactory.php index 8355568a..5f22dad1 100644 --- a/apps/platform/app/Support/Verification/BlockedVerificationReportFactory.php +++ b/apps/platform/app/Support/Verification/BlockedVerificationReportFactory.php @@ -53,12 +53,17 @@ public static function identity(OperationRun $run): array $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; - $entraTenantId = $targetScope['entra_tenant_id'] ?? null; - if (is_string($entraTenantId) && trim($entraTenantId) !== '') { - $identity['entra_tenant_id'] = trim($entraTenantId); + $targetScopeIdentifier = self::targetScopeIdentifier($targetScope); + if ($targetScopeIdentifier !== null) { + $identity['target_scope'] = self::targetScopeIdentity($targetScope); + } else { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $identity['entra_tenant_id'] = trim($entraTenantId); + } } - $connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null); + $connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null)); if (is_string($connectionType) && trim($connectionType) !== '') { $identity['connection_type'] = trim($connectionType); } @@ -124,15 +129,23 @@ private static function evidence(OperationRun $run, array $context): array $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; - $entraTenantId = $targetScope['entra_tenant_id'] ?? null; - if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $targetScopeIdentifier = self::targetScopeIdentifier($targetScope); + if ($targetScopeIdentifier !== null) { $evidence[] = [ - 'kind' => 'entra_tenant_id', - 'value' => trim($entraTenantId), + 'kind' => 'target_scope_identifier', + 'value' => $targetScopeIdentifier, ]; + } else { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $evidence[] = [ + 'kind' => 'entra_tenant_id', + 'value' => trim($entraTenantId), + ]; + } } - $connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null); + $connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null)); if (is_string($connectionType) && trim($connectionType) !== '') { $evidence[] = [ 'kind' => 'connection_type', @@ -163,4 +176,35 @@ private static function evidence(OperationRun $run, array $context): array return $evidence; } + + /** + * @param array $targetScope + */ + private static function targetScopeIdentifier(array $targetScope): ?string + { + $scopeIdentifier = $targetScope['scope_identifier'] ?? null; + + return is_string($scopeIdentifier) && trim($scopeIdentifier) !== '' + ? trim($scopeIdentifier) + : null; + } + + /** + * @param array $targetScope + * @return array + */ + private static function targetScopeIdentity(array $targetScope): array + { + $identity = []; + + foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) { + $value = $targetScope[$key] ?? null; + + if (is_string($value) && trim($value) !== '') { + $identity[$key] = trim($value); + } + } + + return $identity; + } } diff --git a/apps/platform/app/Support/Verification/StaleQueuedVerificationReportFactory.php b/apps/platform/app/Support/Verification/StaleQueuedVerificationReportFactory.php index 93fde618..3caeb42c 100644 --- a/apps/platform/app/Support/Verification/StaleQueuedVerificationReportFactory.php +++ b/apps/platform/app/Support/Verification/StaleQueuedVerificationReportFactory.php @@ -45,9 +45,14 @@ public static function identity(OperationRun $run): array $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; - $entraTenantId = $targetScope['entra_tenant_id'] ?? null; - if (is_string($entraTenantId) && trim($entraTenantId) !== '') { - $identity['entra_tenant_id'] = trim($entraTenantId); + $targetScopeIdentifier = self::targetScopeIdentifier($targetScope); + if ($targetScopeIdentifier !== null) { + $identity['target_scope'] = self::targetScopeIdentity($targetScope); + } else { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $identity['entra_tenant_id'] = trim($entraTenantId); + } } return $identity; @@ -72,12 +77,20 @@ private static function evidence(OperationRun $run, array $context): array $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; - $entraTenantId = $targetScope['entra_tenant_id'] ?? null; - if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $targetScopeIdentifier = self::targetScopeIdentifier($targetScope); + if ($targetScopeIdentifier !== null) { $evidence[] = [ - 'kind' => 'entra_tenant_id', - 'value' => trim($entraTenantId), + 'kind' => 'target_scope_identifier', + 'value' => $targetScopeIdentifier, ]; + } else { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $evidence[] = [ + 'kind' => 'entra_tenant_id', + 'value' => trim($entraTenantId), + ]; + } } $evidence[] = [ @@ -87,4 +100,35 @@ private static function evidence(OperationRun $run, array $context): array return $evidence; } + + /** + * @param array $targetScope + */ + private static function targetScopeIdentifier(array $targetScope): ?string + { + $scopeIdentifier = $targetScope['scope_identifier'] ?? null; + + return is_string($scopeIdentifier) && trim($scopeIdentifier) !== '' + ? trim($scopeIdentifier) + : null; + } + + /** + * @param array $targetScope + * @return array + */ + private static function targetScopeIdentity(array $targetScope): array + { + $identity = []; + + foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) { + $value = $targetScope[$key] ?? null; + + if (is_string($value) && trim($value) !== '') { + $identity[$key] = trim($value); + } + } + + return $identity; + } } diff --git a/apps/platform/app/Support/Verification/VerificationReportSanitizer.php b/apps/platform/app/Support/Verification/VerificationReportSanitizer.php index 5435c983..14e96299 100644 --- a/apps/platform/app/Support/Verification/VerificationReportSanitizer.php +++ b/apps/platform/app/Support/Verification/VerificationReportSanitizer.php @@ -13,6 +13,7 @@ final class VerificationReportSanitizer */ private const ALLOWED_EVIDENCE_KINDS = [ 'provider_connection_id', + 'target_scope_identifier', 'entra_tenant_id', 'connection_type', 'credential_source', @@ -108,7 +109,7 @@ public static function sanitizeReport(array $report): array /** * @param array $identity - * @return array + * @return array */ private static function sanitizeIdentity(array $identity): array { @@ -123,6 +124,16 @@ private static function sanitizeIdentity(array $identity): array continue; } + if ($key === 'target_scope' && is_array($value)) { + $targetScope = self::sanitizeIdentityTargetScope($value); + + if ($targetScope !== []) { + $sanitized[$key] = $targetScope; + } + + continue; + } + if (is_int($value)) { $sanitized[$key] = $value; @@ -143,6 +154,31 @@ private static function sanitizeIdentity(array $identity): array return $sanitized; } + /** + * @param array $targetScope + * @return array + */ + private static function sanitizeIdentityTargetScope(array $targetScope): array + { + $sanitized = []; + + foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) { + $value = $targetScope[$key] ?? null; + + if (! is_string($value)) { + continue; + } + + $value = self::sanitizeValueString($value); + + if ($value !== null) { + $sanitized[$key] = $value; + } + } + + return $sanitized; + } + /** * @param array $summary * @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null diff --git a/apps/platform/config/provider_boundaries.php b/apps/platform/config/provider_boundaries.php index e3d16222..9e0e71f8 100644 --- a/apps/platform/config/provider_boundaries.php +++ b/apps/platform/config/provider_boundaries.php @@ -40,18 +40,19 @@ 'target scope', 'credential source', 'effective client identity', + 'provider context', ], 'retained_provider_semantics' => [ - 'entra_tenant_id', + 'provider_context.microsoft_tenant_id', 'platform_config', 'graph.tenant_id', 'admin.consent.callback', ], - 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC, + 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, ], 'provider.connection_resolution' => [ 'owner' => ProviderBoundaryOwner::PlatformCore->value, - 'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.', + 'description' => 'Platform-core provider connection selection and validation path that publishes neutral target-scope truth with provider-specific profile detail kept as bounded context metadata.', 'implementation_paths' => [ 'app/Services/Providers/ProviderConnectionResolver.php', 'app/Services/Providers/ProviderConnectionResolution.php', @@ -59,16 +60,16 @@ 'neutral_terms' => [ 'provider', 'provider connection', - 'tenant scope', + 'target scope', 'default binding', 'unsupported combination', ], 'retained_provider_semantics' => [ 'microsoft', - 'entra_tenant_id', + 'provider_context.microsoft_tenant_id', 'consent_status', ], - 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC, + 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, ], 'provider.operation_registry' => [ 'owner' => ProviderBoundaryOwner::PlatformCore->value, @@ -94,7 +95,7 @@ ], 'provider.operation_start_gate' => [ 'owner' => ProviderBoundaryOwner::PlatformCore->value, - 'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.', + 'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records neutral target-scope context with provider-specific follow-up detail nested separately.', 'implementation_paths' => [ 'app/Services/Providers/ProviderOperationStartGate.php', ], @@ -107,9 +108,9 @@ ], 'retained_provider_semantics' => [ 'microsoft', - 'target_scope.entra_tenant_id', + 'provider_context.microsoft_tenant_id', ], - 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC, + 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, ], ], ]; diff --git a/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php b/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php index 9f649cf6..ef7995b3 100644 --- a/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php @@ -14,6 +14,7 @@ $verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; + $targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null; $isMissing = $connectionState === 'missing'; $lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle); @@ -26,9 +27,9 @@
Provider connection
@if ($isMissing) -
Needs action: no Microsoft provider connection is configured.
+
Needs action: no provider connection is configured.
@elseif ($needsDefaultConnection) -
Needs action: set a default Microsoft provider connection.
+
Needs action: set a default provider connection.
@else
{{ $displayName ?? 'Unnamed connection' }}
@endif @@ -51,6 +52,10 @@
Provider
{{ $provider ?? 'n/a' }}
+
+
Target scope
+
{{ $targetScopeSummary ?? 'n/a' }}
+
Lifecycle
diff --git a/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php b/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php index c3ac1aad..d5afc7de 100644 --- a/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php +++ b/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php @@ -3,15 +3,15 @@ wire:poll.{{ $pollingInterval }} @endif data-testid="tenant-dashboard-context-chips" - class="grid gap-3 md:grid-cols-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center" + class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap" > -
+
{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}
@if (filled($context['provider'] ?? null)) -
+
@if (($context['providerKey'] ?? null) === 'microsoft')
+
{{ __('localization.dashboard.overview.context_latest_activity_chip', ['time' => $context['latestActivity']]) }}
diff --git a/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php b/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php index c02ead91..defa52a7 100644 --- a/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php +++ b/apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php @@ -72,6 +72,8 @@ ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]') !== null", true) ->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true) ->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true) + ->assertScript("(() => { const subtitle = Array.from(document.querySelectorAll('p')).find((node) => node.textContent?.includes('Tenant governance overview')); const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); if (! subtitle || ! chips) return false; return chips.getBoundingClientRect().top - subtitle.getBoundingClientRect().bottom <= 40; })()", true) + ->assertScript("(() => { const workspace = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-workspace\"]'); const provider = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-provider\"]'); const activity = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]'); if (! workspace || ! provider || ! activity) return false; const tops = [workspace, provider, activity].map((element) => Math.round(element.getBoundingClientRect().top)); return Math.max(...tops) - Math.min(...tops) <= 2; })()", true) ->assertSee('Recommended next actions') ->assertSee('Operations needing attention') ->assertSee('Operations requiring attention') diff --git a/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php b/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php new file mode 100644 index 00000000..ef3027e1 --- /dev/null +++ b/apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php @@ -0,0 +1,86 @@ +browser()->timeout(20_000); + +it('smokes provider-connection detail and managed-environment related provider summary continuity', function (): void { + [$user, $tenant] = createUserWithTenant( + role: 'owner', + workspaceRole: 'manager', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $tenant->forceFill([ + 'name' => 'Spec 281 Browser Environment', + 'managed_environment_id' => '88888888-8888-8888-8888-888888888888', + 'status' => ManagedEnvironment::STATUS_ONBOARDING, + ])->save(); + + $connection = ProviderConnection::factory()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Spec 281 Browser Connection', + 'entra_tenant_id' => '88888888-8888-8888-8888-888888888888', + 'is_default' => true, + 'verification_status' => 'healthy', + ]); + + $draft = TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => '88888888-8888-8888-8888-888888888888', + 'current_step' => 'connection', + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ], + ]); + + visit(ProviderConnectionResource::getUrl('view', [ + 'record' => $connection, + 'managed_environment_id' => $tenant->external_id, + ], panel: 'admin')) + ->waitForText('Spec 281 Browser Connection') + ->assertSee('Target scope') + ->assertSee('Spec 281 Browser Environment') + ->assertSee('Provider context') + ->assertSee('Microsoft tenant ID') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])) + ->waitForText('Provider connection') + ->assertSee('Ready - Spec 281 Browser Environment') + ->assertSee('Spec 281 Browser Environment') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin')) + ->waitForText('Provider connection') + ->assertSee('Spec 281 Browser Connection') + ->assertSee('Target scope') + ->assertSee('Spec 281 Browser Environment') + ->assertSee('Open Provider Connections') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); diff --git a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php index 0bbf8380..3f3275db 100644 --- a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php +++ b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php @@ -102,7 +102,7 @@ 'provider_connection_id', 'provider', 'target_scope', - 'provider_identity_context', + 'provider_context', 'connection_type', ]) ->and($metadata)->not->toHaveKey('entra_tenant_id') @@ -112,7 +112,10 @@ 'scope_identifier' => '88888888-8888-8888-8888-888888888888', 'shared_label' => 'Target scope', ]) - ->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([ + ->and($metadata['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]) + ->and($metadata['provider_context']['details'][0] ?? [])->toMatchArray([ 'provider' => 'microsoft', 'detail_key' => 'microsoft_tenant_id', 'detail_label' => 'Microsoft tenant ID', diff --git a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php index 130fe9d8..6b6c4ef7 100644 --- a/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php +++ b/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php @@ -98,9 +98,10 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4) ->and($content)->toContain('data-testid="tenant-dashboard-posture-pill"') ->and($content)->toContain('data-testid="tenant-dashboard-context-chips"') - ->and($content)->toContain('lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center') + ->and($content)->toContain('class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap"') ->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace"') - ->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full items-center') + ->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full max-w-full items-center') + ->and($content)->toContain('sm:w-auto sm:max-w-[20rem] lg:max-w-[24rem]') ->and($content)->toContain('Workspace: '.$tenant->workspace->name) ->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider"') ->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider-microsoft-logo"') diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php new file mode 100644 index 00000000..073601cf --- /dev/null +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php @@ -0,0 +1,53 @@ +consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'display_name' => 'Spec 281 visible connection', + 'entra_tenant_id' => '66666666-6666-6666-6666-666666666666', + 'consent_status' => 'granted', + 'verification_status' => 'healthy', + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::actingAs($user)->test(ListProviderConnections::class); + $table = $component->instance()->getTable(); + $visibleColumnNames = collect($table->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); + $globalSearchProperty = new ReflectionProperty(ProviderConnectionResource::class, 'isGloballySearchable'); + $globalSearchProperty->setAccessible(true); + + expect($globalSearchProperty->getValue())->toBeFalse() + ->and(array_keys(ProviderConnectionResource::getPages()))->toContain('view', 'edit') + ->and($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status') + ->and($visibleColumnNames)->not->toContain('entra_tenant_id'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('view', [ + 'record' => $connection, + 'managed_environment_id' => $tenant->external_id, + ], panel: 'admin')) + ->assertOk() + ->assertSee('Target scope') + ->assertSee('Provider context') + ->assertSee('Microsoft tenant ID') + ->assertDontSee('Entra tenant ID'); +}); diff --git a/apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php b/apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php new file mode 100644 index 00000000..3adee7a6 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php @@ -0,0 +1,36 @@ + [ + 'tenantContext', + 'target_scope.entra_tenant_id', + ], + 'app/Services/Providers/ProviderIdentityResolver.php' => [ + 'tenantContext', + ], + 'app/Services/Providers/PlatformProviderIdentityResolver.php' => [ + 'tenantContext', + ], + 'app/Services/Providers/ProviderOperationStartGate.php' => [ + "'entra_tenant_id' =>", + 'target_scope.entra_tenant_id', + ], + 'config/provider_boundaries.php' => [ + 'target_scope.entra_tenant_id', + ], + ]; + + foreach ($forbiddenByPath as $relativePath => $fragments) { + $contents = (string) file_get_contents($root.'/'.$relativePath); + + foreach ($fragments as $fragment) { + expect($contents) + ->not->toContain($fragment, sprintf('%s still exposes [%s] as shared provider-scope truth.', $relativePath, $fragment)); + } + } +}); diff --git a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php new file mode 100644 index 00000000..7c173cbb --- /dev/null +++ b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php @@ -0,0 +1,60 @@ +consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Spec 281 onboarding connection', + 'entra_tenant_id' => '77777777-7777-7777-7777-777777777777', + 'is_default' => true, + 'verification_status' => 'healthy', + ]); + + $draft = TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => '77777777-7777-7777-7777-777777777777', + 'current_step' => 'connection', + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $component = Livewire::actingAs($user) + ->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]); + + $method = new ReflectionMethod($component->instance(), 'readinessProviderSummary'); + $method->setAccessible(true); + + $summary = $method->invoke($component->instance(), $connection->fresh(['tenant'])); + + expect($summary['provider'])->toBe('microsoft') + ->and($summary['target_scope'] ?? [])->toMatchArray([ + 'scope_identifier' => '77777777-7777-7777-7777-777777777777', + 'shared_label' => 'Target scope', + ]) + ->and($summary['target_scope_summary'] ?? null)->toBe($tenant->name.' (77777777-7777-7777-7777-777777777777)') + ->and($summary['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]) + ->and($summary['target_scope'])->not->toHaveKey('entra_tenant_id') + ->and($summary)->not->toHaveKey('contextual_identity_details'); +}); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php index 81c9d5d8..1fad2206 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php @@ -109,9 +109,15 @@ public function request(string $method, string $path, array $options = []): Grap 'provider' => 'microsoft', 'module' => 'compliance', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - 'entra_tenant_name' => 'Contoso', - ], + 'connection_type' => 'platform', ]); + expect($run->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($run->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + 'scope_display_name' => 'Contoso', + ])->not->toHaveKey('entra_tenant_id'); }); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php index 3bd335b9..a85e6771 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php @@ -105,12 +105,17 @@ public function request(string $method, string $path, array $options = []): Grap expect($run->status)->toBe('completed'); expect($run->outcome)->toBe('succeeded'); expect($run->context)->toMatchArray([ - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - 'entra_tenant_name' => 'Contoso', - 'connection_type' => 'dedicated', - ], + 'connection_type' => 'dedicated', ]); + expect($run->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($run->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + 'scope_display_name' => 'Contoso', + ])->not->toHaveKey('entra_tenant_id'); expect($connection->metadata)->toMatchArray([ 'entra_tenant_name' => 'Contoso', diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php index eca22fb2..1eb0db29 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php @@ -52,10 +52,15 @@ 'provider' => 'microsoft', 'module' => 'health_check', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - ], ]); + expect($opRun?->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($opRun?->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + ])->not->toHaveKey('entra_tenant_id'); $notifications = session('filament.notifications', []); expect($notifications)->not->toBeEmpty(); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php index 25d15f6d..f51e0445 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -52,10 +52,15 @@ 'provider' => 'microsoft', 'module' => 'inventory', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - ], ]); + expect($opRun?->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($opRun?->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + ])->not->toHaveKey('entra_tenant_id'); expect(OperationRun::query() ->where('managed_environment_id', $tenant->getKey()) @@ -105,10 +110,15 @@ 'provider' => 'microsoft', 'module' => 'inventory', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - ], ]); + expect($opRun?->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($opRun?->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + ])->not->toHaveKey('entra_tenant_id'); Queue::assertPushed(ProviderInventorySyncJob::class, 1); }); @@ -153,10 +163,15 @@ 'provider' => 'microsoft', 'module' => 'compliance', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $connection->entra_tenant_id, - ], ]); + expect($opRun?->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($opRun?->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => $connection->entra_tenant_id, + ])->not->toHaveKey('entra_tenant_id'); expect(OperationRun::query() ->where('managed_environment_id', $tenant->getKey()) diff --git a/apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php b/apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php new file mode 100644 index 00000000..53dba737 --- /dev/null +++ b/apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php @@ -0,0 +1,57 @@ +set('graph.client_id', 'platform-client-id'); + config()->set('graph.client_secret', 'platform-client-secret'); + + [, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory() + ->platform() + ->consentGranted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Spec 281 provider connection', + 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', + 'is_default' => true, + 'is_enabled' => true, + 'verification_status' => 'healthy', + ]); + + $resolution = app(ProviderConnectionResolver::class) + ->validateConnection($tenant, 'microsoft', $connection->fresh(['tenant'])); + $summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant'])); + $summaryPayload = $summary->toArray(); + + expect($resolution->resolved)->toBeTrue() + ->and($resolution->targetScope?->toArray())->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => '11111111-1111-1111-1111-111111111111', + 'shared_label' => 'Target scope', + ]) + ->and($resolution->targetScope?->toArray())->not->toHaveKey('entra_tenant_id') + ->and($summaryPayload['provider'])->toBe('microsoft') + ->and($summaryPayload['target_scope'] ?? [])->toMatchArray([ + 'scope_identifier' => '11111111-1111-1111-1111-111111111111', + ]) + ->and($summaryPayload['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]) + ->and($summaryPayload['target_scope'])->not->toHaveKey('entra_tenant_id') + ->and($summaryPayload['provider_context']['details'][0] ?? [])->toMatchArray([ + 'detail_key' => 'microsoft_tenant_id', + 'detail_value' => '11111111-1111-1111-1111-111111111111', + ]); +}); diff --git a/apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php b/apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php new file mode 100644 index 00000000..b97244c8 --- /dev/null +++ b/apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php @@ -0,0 +1,66 @@ +set('graph.client_id', 'platform-client-id'); + config()->set('graph.client_secret', 'platform-client-secret'); + config()->set('graph.managed_environment_id', 'platform-home-tenant-id'); + + [, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->platform()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '22222222-2222-2222-2222-222222222222', + ]); + + $resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant'])); + $providerContextDetails = collect($resolution->providerContext()['details']); + + expect($resolution->resolved)->toBeTrue() + ->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform) + ->and(property_exists($resolution, 'tenantContext'))->toBeFalse() + ->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222') + ->and($resolution->effectiveClientIdentity())->toBe([ + 'client_id' => 'platform-client-id', + 'credential_source' => 'platform_config', + ]) + ->and($providerContextDetails->contains( + fn (array $detail): bool => ($detail['detail_key'] ?? null) === 'microsoft_tenant_id' + && ($detail['detail_value'] ?? null) === '22222222-2222-2222-2222-222222222222', + ))->toBeTrue(); +}); + +it('keeps dedicated runtime secrets out of target scope and provider context', function (): void { + $connection = ProviderConnection::factory()->dedicated()->create([ + 'entra_tenant_id' => '33333333-3333-3333-3333-333333333333', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'dedicated-client-id', + 'client_secret' => 'dedicated-client-secret', + ], + ]); + + $resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential'])); + $providerContextDetails = collect($resolution->providerContext()['details']); + + expect($resolution->resolved)->toBeTrue() + ->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret') + ->and($providerContextDetails->contains( + fn (array $detail): bool => str_contains((string) ($detail['detail_value'] ?? ''), 'dedicated-client-secret'), + ))->toBeFalse() + ->and($resolution->effectiveClientId)->toBe('dedicated-client-id'); +}); diff --git a/apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php b/apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php new file mode 100644 index 00000000..84a86b6e --- /dev/null +++ b/apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php @@ -0,0 +1,93 @@ +create([ + 'name' => 'Spec 281 Environment', + 'managed_environment_id' => '44444444-4444-4444-4444-444444444444', + ]); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '44444444-4444-4444-4444-444444444444', + 'consent_status' => 'granted', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: fn (OperationRun $run): null => null, + ); + + $context = $result->run->fresh()->context; + + expect($result->status)->toBe('started') + ->and($context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => '44444444-4444-4444-4444-444444444444', + 'scope_display_name' => 'Spec 281 Environment', + ]) + ->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id') + ->and($context['provider_context']['details'][0] ?? [])->toMatchArray([ + 'detail_key' => 'microsoft_tenant_id', + 'detail_value' => '44444444-4444-4444-4444-444444444444', + ]); +}); + +it('stores neutral target-scope context when provider starts are blocked before a connection is resolved', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'name' => 'Blocked Spec 281 Environment', + 'managed_environment_id' => '55555555-5555-5555-5555-555555555555', + ]); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: null, + operationType: 'provider.connection.check', + dispatcher: fn (): null => null, + ); + + $context = $result->run->fresh()->context; + $report = $context['verification_report'] ?? []; + $report = is_array($report) ? $report : []; + $identity = $report['identity'] ?? []; + $identity = is_array($identity) ? $identity : []; + $evidence = $report['checks'][0]['evidence'] ?? []; + $evidence = is_array($evidence) ? $evidence : []; + + expect($result->status)->toBe('blocked') + ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and($context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing) + ->and($context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => '55555555-5555-5555-5555-555555555555', + ]) + ->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id') + ->and($identity['target_scope'] ?? [])->toMatchArray([ + 'scope_identifier' => '55555555-5555-5555-5555-555555555555', + ]) + ->and($identity)->not->toHaveKey('entra_tenant_id') + ->and(collect($evidence)->contains( + fn (array $pointer): bool => ($pointer['kind'] ?? null) === 'target_scope_identifier' + && ($pointer['value'] ?? null) === '55555555-5555-5555-5555-555555555555', + ))->toBeTrue(); +}); diff --git a/apps/platform/tests/Unit/Providers/PlatformProviderIdentityResolverTest.php b/apps/platform/tests/Unit/Providers/PlatformProviderIdentityResolverTest.php index f41d0f1b..98b22881 100644 --- a/apps/platform/tests/Unit/Providers/PlatformProviderIdentityResolverTest.php +++ b/apps/platform/tests/Unit/Providers/PlatformProviderIdentityResolverTest.php @@ -13,7 +13,8 @@ expect($resolution->resolved)->toBeTrue() ->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform) - ->and($resolution->tenantContext)->toBe('customer-tenant-id') + ->and(property_exists($resolution, 'tenantContext'))->toBeFalse() + ->and($resolution->targetScopeIdentifier())->toBe('customer-tenant-id') ->and($resolution->effectiveClientId)->toBe('platform-client-id') ->and($resolution->credentialSource)->toBe('platform_config') ->and($resolution->clientSecret)->toBe('platform-client-secret') diff --git a/apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php b/apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php index 0a5c75fa..89749775 100644 --- a/apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php @@ -29,9 +29,9 @@ expect($identity->coversPath('app/Services/Providers/ProviderIdentityResolution.php'))->toBeTrue() ->and($identity->neutralTerms)->toContain('target scope') - ->and($identity->retainedProviderSemantics)->toContain('entra_tenant_id') + ->and($identity->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id') ->and($identity->retainedProviderSemantics)->not->toContain('Microsoft Graph option keys') - ->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_SPEC); + ->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE); $registry = $catalog->get('provider.operation_registry'); @@ -48,7 +48,7 @@ ->and($seam->description)->not->toBeEmpty() ->and($seam->implementationPaths)->not->toBeEmpty() ->and($seam->neutralTerms)->not->toBeEmpty() - ->and($seam->retainedProviderSemantics)->toContain('target_scope.entra_tenant_id') + ->and($seam->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id') ->and($seam->followUpAction)->toBeIn([ ProviderBoundarySeam::FOLLOW_UP_NONE, ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, diff --git a/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php b/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php index 7f781de9..d399e2a2 100644 --- a/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php @@ -32,11 +32,12 @@ expect($resolution->resolved)->toBeTrue() ->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform) - ->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222') + ->and(property_exists($resolution, 'tenantContext'))->toBeFalse() + ->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222') ->and($resolution->targetScope)->not->toBeNull() ->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) ->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222') - ->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all()) + ->and(collect($resolution->providerContextDetails)->pluck('detailKey')->all()) ->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri'); }); diff --git a/apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php b/apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php index ff4f2503..c02541a2 100644 --- a/apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php @@ -88,7 +88,8 @@ $resolution = app(ProviderIdentityResolver::class)->resolve($connection); expect($resolution->resolved)->toBeTrue() - ->and($resolution->tenantContext)->toBe('dedicated-target-tenant-id') + ->and(property_exists($resolution, 'tenantContext'))->toBeFalse() + ->and($resolution->targetScopeIdentifier())->toBe('dedicated-target-tenant-id') ->and($resolution->effectiveClientId)->toBe('dedicated-client-id') ->and(method_exists($resolution, 'graphOptions'))->toBeFalse(); }); diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php index 048fc89c..9aba9546 100644 --- a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -53,10 +53,15 @@ 'provider' => 'microsoft', 'module' => 'health_check', 'provider_connection_id' => (int) $connection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => 'entra-tenant-id', - ], ]); + expect($run->context['provider_context'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + ]); + expect($run->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => 'entra-tenant-id', + ])->not->toHaveKey('entra_tenant_id'); expect($run->context['provider_binding']['provider'] ?? null)->toBe('microsoft') ->and($run->context['provider_binding']['binding_status'] ?? null)->toBe('active'); }); @@ -197,10 +202,12 @@ expect($run->context)->toMatchArray([ 'provider_connection_id' => (int) $connection->getKey(), 'required_capability' => Capabilities::TENANT_MANAGE, - 'target_scope' => [ - 'entra_tenant_id' => 'restore-entra-tenant-id', - ], ]); + expect($run->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => 'restore-entra-tenant-id', + ])->not->toHaveKey('entra_tenant_id'); }); it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void { @@ -239,10 +246,12 @@ expect($run->context)->toMatchArray([ 'provider_connection_id' => (int) $connection->getKey(), 'required_capability' => Capabilities::TENANT_SYNC, - 'target_scope' => [ - 'entra_tenant_id' => 'directory-entra-tenant-id', - ], ]); + expect($run->context['target_scope'] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => 'directory-entra-tenant-id', + ])->not->toHaveKey('entra_tenant_id'); }); it('treats onboarding bootstrap provider starts as one protected scope', function (): void { diff --git a/specs/281-provider-connection-scope/checklists/requirements.md b/specs/281-provider-connection-scope/checklists/requirements.md new file mode 100644 index 00000000..8a7e70b9 --- /dev/null +++ b/specs/281-provider-connection-scope/checklists/requirements.md @@ -0,0 +1,68 @@ +# Specification Quality Checklist: Provider Connection Scope & Microsoft Profile Extraction + +**Purpose**: Validate package completeness, boundedness, and readiness before implementation +**Created**: 2026-05-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] The package stays on reserved slot `281` and does not silently absorb Spec `280` or Specs `282`-`287`. +- [x] The stale candidate wording about `provider_connections.tenant_id` is explicitly corrected to current repo truth. +- [x] The package explicitly documents the second candidate deviation: the raw `provider_key` / `external_account_id` / `provider_metadata` / run-context proposal is narrowed to existing repo truth through `target_scope`, `effective_client_identity`, nested `provider_context`, and existing provider-owned metadata. +- [x] The package stays focused on the verified provider-boundary hotspot instead of reading like a speculative provider-platform rewrite. +- [x] No new provider-profile table, registry, capability engine, or artifact taxonomy is pulled into scope. +- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the contract artifact all describe the same bounded slice. + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`. +- [x] Requirements remain testable and bounded to the current provider-connection, target-scope, identity-resolution, onboarding, and operation-start seams. +- [x] Shared `target_scope` fields are explicit and neutral across the package. +- [x] Provider-specific Microsoft detail is explicitly nested under provider-owned profile or context disclosure instead of shared contract truth. +- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit. + +## Repo Truth Anchoring + +- [x] The package reflects that `ProviderConnection` already belongs to `ManagedEnvironment` via `managed_environment_id`. +- [x] The package reflects that current platform-core seams still leak Microsoft semantics through `tenantContext` and `target_scope.entra_tenant_id`. +- [x] The package reflects that `config/provider_boundaries.php` already classifies provider identity, connection resolution, and operation-start seams as platform-core follow-up hotspots. +- [x] The package reflects that `ProviderConnectionResource` exists with `Create`, `View`, and `Edit` pages and remains non-globally-searchable. +- [x] The package reflects that `ManagedTenantOnboardingWizard` and managed-environment related-context seams already reuse provider summaries and therefore need one summary contract. + +## Feature Readiness + +- [x] Filament v5 and Livewire v4 expectations remain explicit across the package. +- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`. +- [x] `ProviderConnectionResource` global-search status and touched searchable-surface notes remain explicit. +- [x] Destructive action confirmation and authorization expectations remain explicit for touched provider-connection mutations. +- [x] The unchanged asset strategy and deployment note remain explicit. +- [x] The test strategy and minimal proving commands are explicit and aligned across artifacts. +- [x] The Candidate Selection Gate still explains why `281` is chosen now and why `282`-`287` are deferred. +- [x] The Completed-Spec Guardrail still keeps `279` and `280` separate from this package. + +## Artifact Alignment + +- [x] `research.md` records the same bounded extraction decisions reflected in `plan.md`. +- [x] `data-model.md` models the same neutral `target_scope`, provider-context, effective-client-identity, onboarding, and run-context contracts reflected in the plan and contract file. +- [x] `quickstart.md` uses the same bounded reviewer flow and proof commands as `plan.md`. +- [x] `contracts/provider-connection-scope.logical.openapi.yaml` models the same shared summary, identity-resolution, provider-profile, onboarding-readiness, and operation-start contracts described in the plan. +- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md`. + +## Test Governance + +- [x] Planned proof stays bounded to focused feature coverage, one browser smoke, and the existing guard concept for Microsoft-shaped shared-contract leaks. +- [x] No new heavy-governance family or broad browser matrix is introduced. +- [x] Workspace, managed-environment, provider-connection, and optional credential fixture cost is acknowledged instead of hidden. +- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions. + +## Notes + +- Reviewed against `.specify/memory/constitution.md`, `specs/279-workspace-managed-environment-core/spec.md`, `specs/280-workspace-tenancy-environment-routing/spec.md`, `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Services/Providers/CredentialManager.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, `apps/platform/app/Services/Providers/ProviderGateway.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`, `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, and `apps/platform/config/provider_boundaries.php` on 2026-05-07. +- No application implementation, test execution, or runtime validation was performed while preparing this package. + +## Review Outcome + +- **Outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Reason**: The package turns the ready spec into an implementation-ready plan set that neutralizes shared provider-connection and target-scope contracts, confines Microsoft profile detail to provider-owned seams, and keeps all adjacent routing, taxonomy, RBAC, copy, and quality-gate work deferred. \ No newline at end of file diff --git a/specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml b/specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml new file mode 100644 index 00000000..41c664c4 --- /dev/null +++ b/specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml @@ -0,0 +1,438 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin - Provider Connection Scope & Profile Contract (Conceptual) + version: 0.1.0 + description: | + Conceptual shared-contract artifact for Spec 281. + + This package keeps the existing provider-connection persistence model and + operator surfaces, but makes the shared provider-connection target-scope, + identity-resolution, onboarding-summary, and operation-start context shape + implementable without guessing at field ownership. + + These paths model logical surfaces and shared contracts, not a promise of + new public route families. Public route ownership remains on the existing + Filament resource and pages, with adjacent routing work deferred to Spec 280. +servers: + - url: /logical/provider-connections +paths: + /connections/{connection}/surface-summary: + get: + summary: Resolve the shared provider-connection summary contract + parameters: + - $ref: '#/components/parameters/ConnectionIdentifier' + responses: + '200': + description: Shared provider-connection summary resolved + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-contract-rules: + - Shared `target_scope` must use `ProviderTargetScope`. + - Provider-specific Microsoft detail remains nested under provider context or profile metadata. + /connections/{connection}/identity-resolution: + get: + summary: Resolve the shared provider identity-result contract + parameters: + - $ref: '#/components/parameters/ConnectionIdentifier' + responses: + '200': + description: Provider identity resolved or blocked with a neutral shared contract + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderIdentityResolutionView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-contract-rules: + - The shared contract centers on `target_scope`, effective client identity, credential source, and blocked reason. + - Provider-specific authority or redirect detail stays nested in `provider_context`. + /connections/{connection}/provider-profile: + get: + summary: Resolve provider-owned profile and contextual disclosure + parameters: + - $ref: '#/components/parameters/ConnectionIdentifier' + responses: + '200': + description: Provider-owned profile disclosure resolved + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderProfileDisclosureView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-provider-owned: true + /onboarding/provider-connections/{connection}/readiness: + get: + summary: Resolve the onboarding readiness summary for an existing provider connection + parameters: + - $ref: '#/components/parameters/ConnectionIdentifier' + responses: + '200': + description: Onboarding readiness payload resolved + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingProviderConnectionReadinessView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-contract-rules: + - Onboarding must reuse the same `ProviderConnectionSurfaceSummaryView` contract as the provider-connections resource. + /provider-operations/{operationType}/start: + post: + summary: Start or block a provider operation using neutral shared target-scope context + parameters: + - $ref: '#/components/parameters/OperationType' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderOperationStartRequest' + responses: + '200': + description: Existing active run reused or scope marked busy + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderOperationStartResult' + '202': + description: New operation run queued + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderOperationStartResult' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: Operation start blocked with a recorded run and neutral shared target-scope context + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderOperationStartResult' + x-contract-rules: + - Shared run context must use `ProviderOperationRunContext.target_scope` instead of `target_scope.entra_tenant_id`. + - Provider-specific detail needed for follow-up belongs in `provider_context.details`. +components: + parameters: + ConnectionIdentifier: + name: connection + in: path + required: true + schema: + type: integer + OperationType: + name: operationType + in: path + required: true + schema: + type: string + responses: + Forbidden: + description: Actor is in scope but lacks the required provider capability. + NotFound: + description: Provider connection or managed-environment scope is not visible to the actor. + schemas: + ProviderTargetScope: + type: object + required: + - provider + - scope_kind + - scope_identifier + - scope_display_name + - shared_label + - shared_help_text + properties: + provider: + type: string + scope_kind: + type: string + enum: [tenant] + scope_identifier: + type: string + scope_display_name: + type: string + shared_label: + type: string + shared_help_text: + type: string + ProviderContextDetail: + type: object + description: Extensible provider-owned detail item used for profile, consent, required-permissions, domain, portal, audit, or troubleshooting disclosure. + required: + - detail_key + - detail_label + - detail_value + - visibility + properties: + detail_key: + type: string + description: Stable provider-owned detail key such as microsoft_tenant_id, authority_tenant, redirect_uri, admin_consent_url, required_permissions_url, portal_domain, or portal_link. + detail_label: + type: string + detail_value: + type: string + visibility: + type: string + enum: [contextual_only, audit_only, troubleshooting_only] + ProviderContext: + type: object + description: Provider-owned nested context wrapper reused by UI summaries, audit metadata, and provider-operation follow-up surfaces. + required: + - provider + - details + properties: + provider: + type: string + details: + type: array + items: + $ref: '#/components/schemas/ProviderContextDetail' + EffectiveClientIdentity: + type: object + required: + - credential_source + properties: + client_id: + type: string + nullable: true + credential_source: + type: string + BlockedReason: + type: object + required: + - reason_code + properties: + reason_code: + type: string + message: + type: string + nullable: true + ProviderConnectionSurfaceSummaryView: + type: object + required: + - provider + - target_scope + - consent_state + - verification_state + - readiness_summary + - target_scope_summary + - provider_context + - is_enabled + properties: + provider: + type: string + target_scope: + $ref: '#/components/schemas/ProviderTargetScope' + consent_state: + type: string + verification_state: + type: string + readiness_summary: + type: string + target_scope_summary: + type: string + provider_context: + $ref: '#/components/schemas/ProviderContext' + contextual_identity_line: + type: string + nullable: true + is_enabled: + type: boolean + ProviderIdentityResolutionView: + type: object + required: + - resolved + - connection_type + - effective_client_identity + - provider_context + properties: + resolved: + type: boolean + connection_type: + type: string + target_scope: + allOf: + - $ref: '#/components/schemas/ProviderTargetScope' + nullable: true + effective_client_identity: + $ref: '#/components/schemas/EffectiveClientIdentity' + blocked_reason: + allOf: + - $ref: '#/components/schemas/BlockedReason' + nullable: true + provider_context: + $ref: '#/components/schemas/ProviderContext' + ProviderProfileDisclosureView: + type: object + required: + - provider + - target_scope + - provider_context + properties: + provider: + type: string + target_scope: + $ref: '#/components/schemas/ProviderTargetScope' + provider_context: + $ref: '#/components/schemas/ProviderContext' + PermissionOverview: + type: object + required: + - overall + - counts + - freshness + - missing_permissions + properties: + overall: + type: string + nullable: true + counts: + type: object + additionalProperties: + type: integer + freshness: + type: object + required: [last_refreshed_at, is_stale] + properties: + last_refreshed_at: + type: string + nullable: true + is_stale: + type: boolean + missing_permissions: + type: object + required: [application, delegated] + properties: + application: + type: array + items: + type: string + delegated: + type: array + items: + type: string + required_permissions_url: + type: string + nullable: true + OnboardingProviderConnectionReadinessView: + type: object + required: + - provider_connection_id + - provider_summary + - permission_overview + properties: + provider_connection_id: + type: integer + provider_summary: + $ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView' + permission_overview: + $ref: '#/components/schemas/PermissionOverview' + ProviderBindingContext: + type: object + required: + - provider + - binding_status + - handler_notes + - exception_notes + properties: + provider: + type: string + binding_status: + type: string + handler_notes: + type: string + exception_notes: + type: string + ProviderOperationRunContext: + type: object + required: + - provider + - module + - provider_binding + - target_scope + properties: + execution_authority_mode: + type: string + nullable: true + required_capability: + type: string + nullable: true + provider: + type: string + module: + type: string + provider_binding: + $ref: '#/components/schemas/ProviderBindingContext' + provider_connection_id: + type: integer + nullable: true + target_scope: + $ref: '#/components/schemas/ProviderTargetScope' + provider_context: + allOf: + - $ref: '#/components/schemas/ProviderContext' + nullable: true + OperationRunReference: + type: object + required: + - id + - type + - status + - context + properties: + id: + type: integer + type: + type: string + status: + type: string + outcome: + type: string + nullable: true + context: + $ref: '#/components/schemas/ProviderOperationRunContext' + ProviderOperationStartRequest: + type: object + required: + - managed_environment_id + properties: + managed_environment_id: + type: integer + provider_connection_id: + type: integer + nullable: true + execution_authority_mode: + type: string + nullable: true + extra_context: + type: object + additionalProperties: true + ProviderOperationStartResult: + type: object + required: + - result + - run + properties: + result: + type: string + enum: [started, deduped, scope_busy, blocked] + run: + $ref: '#/components/schemas/OperationRunReference' + blocked_reason: + allOf: + - $ref: '#/components/schemas/BlockedReason' + nullable: true \ No newline at end of file diff --git a/specs/281-provider-connection-scope/data-model.md b/specs/281-provider-connection-scope/data-model.md new file mode 100644 index 00000000..2e5240d0 --- /dev/null +++ b/specs/281-provider-connection-scope/data-model.md @@ -0,0 +1,232 @@ +# Data Model: Provider Connection Scope & Microsoft Profile Extraction + +**Date**: 2026-05-07 +**Branch**: `281-provider-connection-scope` + +## Overview + +This slice introduces no new persistence. It keeps the existing provider-connection, credential, and run records intact and instead standardizes the derived runtime contracts that platform-core seams expose to UI, audit, and provider-operation flows. + +## Persisted Truth Unchanged + +- `ProviderConnection` remains the workspace-owned, managed-environment-scoped binding record. +- `ProviderCredential` remains the optional credential record attached to one `ProviderConnection`. +- `OperationRun` remains execution truth and keeps its current identity and lifecycle ownership. +- `config/provider_boundaries.php` remains the single source for provider-owned versus platform-core seam classification. +- No new table, registry, provider-profile entity, enum family, or taxonomy is introduced. + +## Derived Runtime Contracts + +### 1. Provider Connection Record + +**Persistence**: existing database row +**Owner**: `ProviderConnection` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | int | yes | Existing record key | +| `workspace_id` | int | yes | Existing workspace boundary | +| `managed_environment_id` | int | yes | Existing managed-environment boundary; already the canonical scope anchor | +| `provider` | string | yes | Current provider key (`microsoft` today) | +| `display_name` | string | yes | Operator-visible connection label | +| `connection_type` | enum | yes | Existing `platform` or `dedicated` connection type | +| `is_default` | bool | yes | Existing default-binding flag | +| `is_enabled` | bool | yes | Existing enablement flag | +| `consent_status` | enum | yes | Existing consent state | +| `verification_status` | enum | yes | Existing verification state | +| `entra_tenant_id` | string | yes | Existing provider-owned persisted identifier; not the shared contract key after this slice | +| `metadata` | array | no | Existing legacy-identity and provider-owned metadata | + +**Rules**: + +- `managed_environment_id` remains the persisted scope anchor; the stale candidate move is not reopened. +- `entra_tenant_id` may remain a provider-owned stored value, but platform-core consumers must read the normalized `target_scope` contract instead of exposing this column name as shared truth. +- `metadata` remains derived/provider-owned detail and must not become a second canonical shared scope contract. + +### 2. Shared Target Scope Descriptor + +**Persistence**: derived +**Owner**: `ProviderConnectionTargetScopeDescriptor` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider` | string | yes | Shared provider key | +| `scope_kind` | string | yes | Current release supports `tenant` only | +| `scope_identifier` | string | yes | Neutral scope identifier used across platform-core seams | +| `scope_display_name` | string | yes | Operator-facing name for the scope | +| `shared_label` | string | yes | Current shared label, `Target scope` | +| `shared_help_text` | string | yes | Current shared help text | + +**Rules**: + +- This is the canonical shared `target_scope` object for connection resolution, identity resolution, audit metadata, surface summaries, onboarding readiness, and provider-operation start context. +- Shared `target_scope` payloads must not require `entra_tenant_id` as a top-level key. +- `scope_kind` remains the current `tenant` constant; this slice does not add new scope-state machinery. + +### 3. Provider Context + +**Persistence**: derived +**Owner**: `ProviderIdentityContextMetadata` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider` | string | yes | Provider key for the disclosed context | +| `details` | list | yes | Ordered provider-owned detail items for profile, consent, audit, or troubleshooting disclosure | + +**Nested `provider_context.details` item** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `detail_key` | string | yes | Stable provider-owned detail key such as `microsoft_tenant_id`, `admin_consent_url`, `required_permissions_url`, or `portal_domain` | +| `detail_label` | string | yes | Operator/support label | +| `detail_value` | string | yes | Current provider-owned value | +| `visibility` | string | yes | `contextual_only`, `audit_only`, or `troubleshooting_only` | + +**Rules**: + +- `provider_context` is the canonical nested provider-owned wrapper carried by shared identity, summary, onboarding, audit, and run-context contracts. +- Current Microsoft details include `microsoft_tenant_id`, `authority_tenant`, `redirect_uri`, consent links, required-permissions guidance, domains, and portal/profile links. +- The detail set is intentionally provider-owned and extensible; this slice does not freeze provider context to a three-key catalog. +- These values may appear in nested provider profile/context blocks or audit metadata, but they do not replace the shared `target_scope` descriptor. + +### 4. Provider Identity Resolution Contract + +**Persistence**: derived +**Owner**: `ProviderIdentityResolution` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `resolved` | bool | yes | Shared resolved/blocked status | +| `connection_type` | string | yes | Existing connection-type truth | +| `target_scope` | object | no | Canonical shared `target_scope` descriptor | +| `effective_client_identity.client_id` | string | no | Neutral shared client identity when resolved | +| `effective_client_identity.credential_source` | string | yes | Shared credential source (`platform_config`, dedicated source, legacy source) | +| `blocked_reason.reason_code` | string | no | Existing provider reason code when blocked | +| `blocked_reason.message` | string | no | Operator-facing blocked reason message | +| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` | + +**Rules**: + +- The shared contract center is `target_scope`, effective client identity, credential source, and blocked reason. +- Legacy `tenantContext` is an implementation concern that should be absorbed into nested provider context or authority handling, not left as the primary shared contract field name. +- `clientSecret` remains runtime-only and is excluded from surface and audit contracts. +- Blocked results still return `target_scope` when it can be normalized, so surfaces keep one consistent summary even on failure. + +### 5. Provider Connection Surface Summary + +**Persistence**: derived +**Owner**: `ProviderConnectionSurfaceSummary` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider` | string | yes | Shared provider key | +| `target_scope` | object | yes | Canonical shared descriptor | +| `consent_state` | string | yes | Existing consent status value | +| `verification_state` | string | yes | Existing verification status value | +| `readiness_summary` | string | yes | Existing operator summary | +| `target_scope_summary` | string | yes | Shared rendered summary for UI surfaces | +| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` | +| `contextual_identity_line` | string | no | Optional condensed display line derived from nested provider context | +| `is_enabled` | bool | yes | Existing enablement state for display logic | + +**Rules**: + +- `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context summaries must all reuse this contract. +- Default-visible content remains summary-first: target scope, readiness, consent, and verification. +- Provider-specific detail is secondary and derived from nested provider context detail only. +- Invalid target scope falls back to an explicit review-needed summary instead of leaking raw provider fields back into shared UI. + +### 6. Onboarding Provider-Connection Readiness View + +**Persistence**: derived +**Owner**: `ManagedTenantOnboardingWizard` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider_connection_id` | int | yes | Selected or referenced connection | +| `provider_summary` | object | yes | Reused `ProviderConnectionSurfaceSummary` payload | +| `permission_overview` | object | yes | Existing required-permissions overview including nested provider-owned `required_permissions_url` guidance | + +**Rules**: + +- Onboarding must reuse the same `provider_summary.target_scope` and `target_scope_summary` contract as the provider-connections resource. +- Supporting verification and permission links remain secondary and stay nested under `permission_overview.required_permissions_url` or equivalent provider-owned guidance fields. +- No onboarding-only target-scope wording or fallback structure is introduced. + +### 7. Provider Operation Run Context + +**Persistence**: derived run context in existing `OperationRun` rows +**Owner**: `ProviderOperationStartGate` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `execution_authority_mode` | string | yes | Existing execution-authority contract | +| `required_capability` | string | no | Existing capability contract | +| `provider` | string | yes | Shared provider key | +| `module` | string | yes | Existing provider-operation module | +| `provider_binding` | object | yes | Existing registry binding metadata | +| `provider_connection_id` | int | no | Existing binding identity when present | +| `target_scope` | object | yes | Canonical shared descriptor | +| `provider_context` | object | no | Nested provider-owned details when required for follow-up | + +**Rules**: + +- Started and blocked runs must use the same neutral shared `target_scope` schema. +- Shared run context must stop writing `target_scope.entra_tenant_id` as the primary contract. +- Provider-specific fields needed for follow-up or troubleshooting move to nested `provider_context` or equivalent provider-owned metadata. +- Current dedupe identity remains `provider_connection_id` plus existing identity inputs; this slice does not redefine run identity semantics. + +### 8. Credential Scope Validation Invariant + +**Persistence**: derived runtime validation only +**Owner**: `CredentialManager` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider_connection_id` | int | yes | Existing credential owner | +| `payload.client_id` | string | yes | Existing credential field | +| `payload.client_secret` | string | yes | Existing credential field | +| `payload.scope_assertion` | mixed | no | Existing payload assertion if present today | +| `normalized_target_scope_identifier` | string | yes | Derived from canonical shared descriptor | + +**Rules**: + +- No provider-credential schema change is introduced. +- If a payload carries a scope assertion, validation should compare it to the normalized target-scope identifier rather than leaking raw provider-column names into the platform-core error contract. +- Neutral mismatch wording belongs in the shared seam; provider-specific values remain nested metadata only. + +### 9. Provider Boundary Review Record + +**Persistence**: config-driven +**Owner**: `config/provider_boundaries.php` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `seam_key` | string | yes | Boundary seam identifier | +| `owner` | string | yes | `platform_core` or `provider_owned` | +| `neutral_terms` | list | yes | Shared vocabulary allowed at the seam | +| `retained_provider_semantics` | list | yes | Documented provider-specific exceptions | +| `follow_up_action` | string | yes | Existing review follow-up rule | + +**Rules**: + +- `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` remain platform-core seams and must carry only the documented provider-specific exceptions. +- `provider.gateway_runtime` remains provider-owned. +- `config/provider_boundaries.php` stays the single review record for this classification; the slice does not create a new taxonomy. + +## Contract Flow + +1. `ProviderConnection` is loaded inside its current workspace plus managed-environment scope. +2. `ProviderConnectionTargetScopeNormalizer` derives the canonical shared `target_scope` descriptor and the nested `provider_context` wrapper. +3. `ProviderConnectionResolver` validates enablement, consent, and supported binding using the normalized `target_scope` contract. +4. `ProviderIdentityResolver` emits one `ProviderIdentityResolution` result centered on target scope, effective client identity, and nested provider context. +5. `ProviderConnectionSurfaceSummary` renders the same summary contract for the provider-connections resource, onboarding, and related-context surfaces. +6. `ProviderOperationStartGate` records the same neutral `target_scope` contract into `OperationRun` context while nesting any provider-only detail under provider context. +7. Provider-owned consumers such as admin-consent URL shaping and Graph runtime mapping read the nested provider context they need without re-promoting those values into shared platform-core vocabulary. + +## Deferred Boundaries + +- No new provider implementation is introduced. +- No provider-profile table, registry, package engine, or artifact taxonomy is introduced. +- No routing work from Spec `280` is absorbed. +- No RBAC redesign, copy-neutralization, or cutover quality-gate work from Specs `282` through `287` is introduced. \ No newline at end of file diff --git a/specs/281-provider-connection-scope/plan.md b/specs/281-provider-connection-scope/plan.md new file mode 100644 index 00000000..66b78b07 --- /dev/null +++ b/specs/281-provider-connection-scope/plan.md @@ -0,0 +1,288 @@ +# Implementation Plan: Provider Connection Scope & Microsoft Profile Extraction + +**Branch**: `281-provider-connection-scope` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/281-provider-connection-scope/spec.md` + +## Summary + +Prepare the next reserved provider-boundary slice that keeps `ProviderConnection` as the existing managed-environment-scoped binding record but extracts one provider-neutral target-scope and effective-client-identity contract across the current platform-core seams. The narrow implementation path reuses `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, `CredentialManager`, `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and `config/provider_boundaries.php` while explicitly deferring Specs `282` through `287`. + +This plan stays intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, no `tenant_id` to `managed_environment_id` migration is reintroduced, no provider-profile table or registry appears, no routing cutover from Spec `280` is absorbed, no RBAC redesign or taxonomy work is introduced, and no compatibility alias such as shared `tenantContext` or shared `target_scope.entra_tenant_id` survives as platform-core truth. + +## Inherited Baseline / Explicit Delta + +### Inherited baseline + +- Spec `279` already completed the core managed-environment cutover and is completed historical context only. +- Spec `280` already prepared the adjacent workspace-first routing shell and remains separate prepared context only. +- `apps/platform/app/Models/ProviderConnection.php` already anchors provider connections by `workspace_id` plus `managed_environment_id`; no `tenant_id` migration remains for this feature. +- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` already exists with `List`, `Create`, `View`, and `Edit` pages, remains `protected static bool $isGloballySearchable = false;`, and already groups mutating actions behind confirmation-protected Filament actions. +- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php` and `apps/platform/config/provider_boundaries.php` already classify `provider.identity_resolution`, `provider.connection_resolution`, and `provider.operation_start_gate` as platform-core seams with retained Microsoft-specific exceptions that still need follow-through. +- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `ProviderConnectionTargetScopeNormalizer.php`, and `ProviderConnectionSurfaceSummary.php` already expose neutral field names such as `scope_kind`, `scope_identifier`, and `scope_display_name`, but they still derive those values directly from `ProviderConnection::entra_tenant_id` and still emit Microsoft contextual detail. +- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `ProviderIdentityResolution.php`, and `PlatformProviderIdentityResolver.php` still use `tenantContext` as the shared identity field name even though the same path already exposes `credentialSource`, `effectiveClientId`, `targetScope`, and contextual detail lists. +- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` still writes `target_scope.entra_tenant_id` for both started and blocked run context. +- `apps/platform/app/Services/Providers/CredentialManager.php` still validates credential payload scope against `entra_tenant_id` and still reports a Microsoft-shaped mismatch message from a platform-core seam. +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Filament/Resources/TenantResource.php` already reuse provider-connection summary and related-context seams, so they are the existing consumers that must converge on one contract rather than new surfaces that need to be invented. + +### Explicit delta in this plan + +- Keep `ProviderConnection`, `ProviderCredential`, `OperationRun`, and current route ownership intact; the slice is contract extraction only. +- Make `ProviderConnectionTargetScopeDescriptor` plus `ProviderConnectionTargetScopeNormalizer` the canonical shared `target_scope` contract across connection resolution, identity resolution, audit metadata, provider-operation start context, resource summaries, onboarding readiness, and related-context summaries. +- Reshape `ProviderIdentityResolution` around neutral effective-client-identity and target-scope language while confining Microsoft-specific tenant, authority, redirect, and consent semantics to nested provider-owned profile or context detail. +- Update `ProviderOperationStartGate` and the associated audit metadata path so shared `OperationRun` context no longer depends on `target_scope.entra_tenant_id` as the primary contract. +- Keep `ProviderConnectionSurfaceSummary` as the one summary adapter reused by `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries. +- Keep provider-owned Microsoft behavior such as admin-consent URL shaping, Graph runtime option mapping, and Microsoft profile disclosure explicit and secondary instead of turning them into platform-core vocabulary. +- Keep all provider registration, asset handling, routing, RBAC, taxonomy, capability-registry, and broader copy-neutralization work deferred to adjacent specs. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing provider-boundary config and target-scope helper seams +**Storage**: PostgreSQL, no new persistence or schema change in this slice +**Testing**: Pest feature tests, one Pest browser smoke, and focused guard coverage for Microsoft-shaped shared-contract regressions +**Validation Lanes**: fast-feedback, confidence, browser +**Target Platform**: Laravel monolith in `apps/platform` +**Project Type**: web application +**Performance Goals**: preserve current provider-connection resource, onboarding readiness, and provider-operation start responsiveness while changing only contract shaping and shared summaries; no new remote inline work or new asset load path +**Constraints**: no new table or persisted provider-profile truth, no registry or capability engine, no reintroduction of the stale `tenant_id` migration candidate, no routing cutover absorption from Spec `280`, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive actions stay confirmation-protected, asset strategy remains unchanged, and preparation work must stay spec-only +**Scale/Scope**: one provider-neutral target-scope and identity contract across the existing provider resolution, summary, onboarding, and operation-start seams for the single current Microsoft provider implementation + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Models/ProviderConnection.php` +- `apps/platform/app/Services/Providers/ProviderConnectionResolver.php` +- `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` +- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php` +- `apps/platform/app/Services/Providers/ProviderIdentityResolution.php` +- `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php` +- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` +- `apps/platform/app/Services/Providers/CredentialManager.php` +- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php` +- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php` +- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` +- `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` +- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` +- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` +- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- `apps/platform/app/Filament/Resources/TenantResource.php` +- `apps/platform/app/Support/Providers/RequiredPermissionsLinks.php` +- `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php` +- `apps/platform/app/Services/Providers/ProviderGateway.php` +- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php` +- `apps/platform/config/provider_boundaries.php` +- feature, browser, and guard coverage under `apps/platform/tests/Feature` and `apps/platform/tests/Browser` + +## Filament v5 / Provider Resource Notes + +- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4; the slice changes summary and contract shaping only. +- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`. +- **Global search rule**: `ProviderConnectionResource` already remains `isGloballySearchable = false` and still has `View` plus `Edit` pages. No new searchable resource is introduced by this slice. If a touched searchable consumer such as `TenantResource` continues to surface provider summaries, it must keep its existing valid view destination unchanged. +- **Destructive actions**: the touched provider-connection mutations already use `Actions\Action::make(...)->action(...)` with `->requiresConfirmation()` and server-side capability checks. The relevant confirmation-protected actions currently include `set_default`, `enable_dedicated_override`, `rotate_dedicated_credential`, `delete_dedicated_credential`, `revert_to_platform`, `enable_connection`, and `disable_connection`; this slice preserves that behavior. +- **Asset strategy**: no new panel or shared asset registration is planned. Deployment guidance remains unchanged: `cd apps/platform && php artisan filament:assets` is only needed when registered assets change, and this slice adds none. + +## Neutral Target-Scope & Identity Contract Fit + +- Treat `ProviderConnectionTargetScopeDescriptor` as the canonical shared `target_scope` object with `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`. +- Treat `ProviderIdentityResolution` as the canonical shared identity-result object for `resolved`, `connection_type`, `effective client identity`, blocked reason, target scope, and provider-context details. +- Treat `ProviderConnectionSurfaceSummary` as the only shared summary adapter for provider-connection list/detail surfaces, onboarding readiness, and managed-environment related context. +- Treat `ProviderOperationStartGate` as the only platform-core seam allowed to shape `OperationRun` context for provider-start flows. +- Treat `ProviderIdentityContextMetadata` as provider-owned disclosure metadata. Microsoft-specific items such as `microsoft_tenant_id`, `authority_tenant`, and `redirect_uri` stay nested there or in provider-owned profile blocks instead of becoming new shared top-level contract keys. +- Treat `AdminConsentUrlFactory` and `ProviderGateway` as downstream provider-owned consumers that must adapt to the neutral shared identity result if field names change. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed native Filament resource plus existing custom onboarding wizard +- **Shared-family relevance**: provider-connections resource family, managed-environment related context, onboarding readiness/provider summary, shared provider-operation feedback +- **State layers in scope**: page, detail, modal, Livewire state, related-context summaries +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first shared target-scope summary, diagnostics-second readiness and blocked-reason detail, provider-raw/profile detail third and explicitly nested +- **Raw/support gating plan**: capability-gated or contextual-only provider profile detail using existing provider-context metadata visibility +- **One-primary-action / duplicate-truth control**: `ProviderConnectionSurfaceSummary` stays the one shared summary contract so provider-connections, onboarding, and related context do not invent parallel identity stories +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory until shared seams stop exposing Microsoft field names as primary contract truth +- **Special surface test profiles**: standard-native-filament, workflow-hub, global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke +- **Exception path and spread control**: none; the slice removes Microsoft-shaped spread from platform-core seams instead of adding a new exception +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: provider connection resolution, identity resolution, target-scope normalization, provider-connection surface summaries, onboarding readiness summaries, provider-operation start context, provider boundary catalog, and provider-owned consent/runtime consumers +- **Shared abstractions reused**: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolution`, `ProviderIdentityResolution`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `ProviderConnectionResource` surface contract +- **New abstraction introduced? why?**: none planned; the existing target-scope and identity seams are sufficient once their field ownership and vocabulary are corrected +- **Why the existing abstraction was sufficient or insufficient**: the repo already has the right seams and summary objects. What is insufficient is the Microsoft-shaped payload and naming that still flows through them. +- **Bounded deviation / spread control**: Microsoft-specific consent URL, authority, redirect, and profile identifiers may remain in provider-owned nested metadata and provider-runtime seams only; they must not remain the primary shared contract fields + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, start and blocked context only +- **Central contract reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `OperationRunService` lifecycle path +- **Delegated UX behaviors**: queued or blocked start-state messaging, dedupe-or-scope-busy outcomes, run creation identity, and start-result presentation remain on the shared provider-operation path +- **Surface-owned behavior kept local**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` continue to own only connection selection, initiation inputs, and follow-up link placement +- **Queued DB-notification policy**: `N/A` - unchanged shared policy +- **Terminal notification path**: existing central lifecycle mechanism +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: Microsoft admin-consent URL shaping, Microsoft portal or profile disclosure, Graph runtime option mapping, and provider-context detail visibility +- **Platform-core seams**: `provider.connection_resolution`, `provider.identity_resolution`, `provider.operation_start_gate`, target-scope descriptor/normalizer, surface-summary reuse, and shared run or audit context +- **Neutral platform terms / contracts preserved**: `provider connection`, `target scope`, `scope kind`, `scope identifier`, `scope display name`, `effective client identity`, `credential source`, `provider profile`, `provider context`, `workspace`, and `managed environment` +- **Retained provider-specific semantics and why**: Microsoft tenant identifiers, authority tenant, redirect URI, required consent flow, and Graph runtime details remain necessary for the current provider implementation. They stay nested under provider-owned metadata or provider-runtime seams rather than becoming platform-core contract keys. +- **Bounded extraction or follow-up path**: Specs `282` through `287` remain the follow-up path for artifact retargeting, capability registry, taxonomy, RBAC, copy neutralization, and cutover quality gates + +## Constitution Check + +*GATE: Must pass before implementation begins and again after the design artifacts are complete.* + +- Inventory-first / snapshot truth: PASS. The slice changes shared provider-boundary contracts only; inventory and snapshot truth are unchanged. +- Read/write separation: PASS. No new write workflow is introduced; existing provider operations keep their current confirmation, audit, and run-observability path. +- Graph contract path: PASS. No Graph endpoint or contract-registry work is added; provider-owned Graph option shaping remains behind the current provider runtime seam. +- Deterministic capabilities: PASS. Capability requirements remain the current registry-backed provider capabilities. +- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged. +- Workspace isolation: PASS. `ProviderConnection` remains workspace-owned and environment-scoped; no access-boundary change is planned. +- Managed-environment isolation: PASS. Provider-connection resolution and onboarding continue to require the current managed-environment boundary. +- Destructive action discipline: PASS by preservation. Existing confirmation-protected provider-connection mutations remain confirmation-protected and server-authorized. +- Global search safety: PASS. `ProviderConnectionResource` already remains non-globally-searchable and keeps valid view/edit pages. +- OperationRun / Ops-UX: PASS. The slice reuses the shared provider-operation start path and changes only the context contract it records. +- Data minimization: PASS. No new persistence or provider-profile table is introduced; provider-specific detail remains nested metadata. +- Test governance: PASS. Proof remains bounded to focused feature coverage, one browser smoke, and one leak-guard family. +- Proportionality / no premature abstraction: PASS. The plan reuses the current seams instead of introducing a new provider framework, registry, or profile entity. +- Persisted truth / behavioral state: PASS. No new table, enum family, or taxonomy is introduced. +- UI semantics / shared pattern first / Filament-native UI: PASS. Existing resource and wizard surfaces remain the first path and keep one shared summary adapter. +- Provider boundary: PASS with implementation condition. Platform-core seams must stop treating `tenantContext` and `target_scope.entra_tenant_id` as shared truth; Microsoft detail remains explicit and nested. + +**Gate evaluation**: PASS. + +**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-connection-scope.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same neutral `target_scope`, effective-client-identity, provider-profile disclosure, and proving-command contract. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature, Browser +- **Affected validation lanes**: fast-feedback, confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: the change is a shared-contract extraction across resolvers, start-gate context, and two operator-facing summary consumers. Focused feature coverage proves the contract and guard behavior, and one browser smoke proves the resource plus onboarding surfaces still tell the same connection story in the live shell. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` +- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace, managed environment, provider connection, optional provider credential, and operation-run fixtures without broadening shared defaults +- **Expensive defaults or shared helper growth introduced?**: no; any new provider fixture helper should remain opt-in and feature-local +- **Heavy-family additions, promotions, or visibility changes**: none beyond one slice-specific browser smoke already justified in the spec +- **Surface-class relief / special coverage rule**: standard-native-filament relief for the resource and one workflow-hub browser proof for onboarding continuity +- **Closing validation and reviewer handoff**: rerun the exact commands above, verify that shared contract outputs now use neutral `target_scope` keys, verify that `ProviderIdentityResolution` no longer uses a shared `tenantContext` field name for platform-core truth, verify that `ProviderOperationStartGate` stopped writing shared `target_scope.entra_tenant_id`, verify that Microsoft detail appears only inside provider-owned context or profile blocks, verify that `ProviderConnectionResource` stays non-globally-searchable with View/Edit pages intact, confirm the existing destructive actions still require confirmation plus server authorization, and confirm provider registration plus asset strategy remain unchanged +- **Budget / baseline / trend follow-up**: contained feature-local increase only +- **Review-stop questions**: did a new provider-profile table or registry appear, did shared seams keep `tenantContext` or `target_scope.entra_tenant_id` as primary truth, did provider-specific profile detail escape nested metadata, did onboarding and provider-connections diverge into separate summary contracts, did the slice absorb deferred Specs `282` through `287` +- **Escalation path**: `reject-or-split` if implementation introduces new persistence, shared compatibility aliases, routing cutover work, RBAC redesign, or provider-framework machinery +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the adjacent follow-up work is already reserved as Specs `282` through `287`; 281 only needs the bounded contract extraction itself + +## Review Checklist Status + +- **Review checklist artifact**: `checklists/requirements.md` +- **Review outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Escalation rule**: if implementation preserves shared Microsoft-shaped contract fields, adds new provider persistence, or absorbs routing, registry, taxonomy, RBAC, or copy-neutralization work, flip the workflow outcome to `split` or `reject-or-split` + +## Rollout Considerations + +- Land target-scope extraction, identity-result reshaping, surface-summary convergence, and provider-operation context updates as one bounded implementation slice so platform-core truth changes atomically. +- Retarget provider-owned consumers such as admin-consent URL shaping only after the shared identity result is finalized, not as a separate second contract. +- Keep managed-environment related-context summaries and onboarding readiness on the same shared summary adapter before polishing any additional copy. +- Keep Spec `280` route work separate; `281` should consume the existing surfaces and not redefine their route ownership. + +## Risk Controls + +- Reject any implementation that reintroduces the stale `tenant_id` to `managed_environment_id` candidate work on `ProviderConnection`. +- Reject any implementation that adds a provider-profile table, registry, capability engine, package abstraction, or artifact taxonomy. +- Reject any implementation that leaves shared `tenantContext` or shared `target_scope.entra_tenant_id` in platform-core contracts as compatibility aliases. +- Reject any implementation that duplicates summary logic outside `ProviderConnectionSurfaceSummary` for provider-connections, onboarding, or related context. +- Reject any implementation that moves provider registration out of `apps/platform/bootstrap/providers.php` or adds new Filament asset registration for this slice. + +## Research & Design Outputs + +- `research.md` records the bounded extraction decisions for persisted truth, shared target-scope ownership, shared identity-result ownership, provider-owned Microsoft profile disclosure, start-gate context shape, and proof strategy. +- `data-model.md` captures the unchanged persisted truth plus the derived `target_scope`, effective-client-identity, provider-context, surface-summary, onboarding-readiness, and run-context contracts. +- `quickstart.md` gives reviewers the bounded package review flow and the exact proving commands. +- `contracts/provider-connection-scope.logical.openapi.yaml` captures the conceptual summary, provider-profile, onboarding-readiness, and operation-start contracts with the neutral shared `target_scope` schema. +- `checklists/requirements.md` records package readiness, boundedness, and outcome state. + +## Project Structure + +### Documentation (this feature) + +```text +specs/281-provider-connection-scope/ +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── provider-connection-scope.logical.openapi.yaml +├── data-model.md +├── plan.md +├── quickstart.md +├── research.md +├── spec.md +└── tasks.md +``` + +### Source Code (expected implementation surfaces) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Workspaces/ +│ │ │ └── ManagedTenantOnboardingWizard.php +│ │ └── Resources/ +│ │ ├── ProviderConnectionResource.php +│ │ ├── TenantResource.php +│ │ └── ProviderConnectionResource/ +│ │ └── Pages/ +│ ├── Models/ +│ │ └── ProviderConnection.php +│ ├── Services/ +│ │ └── Providers/ +│ │ ├── AdminConsentUrlFactory.php +│ │ ├── CredentialManager.php +│ │ ├── PlatformProviderIdentityResolver.php +│ │ ├── ProviderConnectionResolution.php +│ │ ├── ProviderConnectionResolver.php +│ │ ├── ProviderGateway.php +│ │ ├── ProviderIdentityResolution.php +│ │ ├── ProviderIdentityResolver.php +│ │ └── ProviderOperationStartGate.php +│ └── Support/ +│ └── Providers/ +│ ├── Boundary/ +│ │ └── ProviderBoundaryCatalog.php +│ └── TargetScope/ +│ ├── ProviderConnectionSurfaceSummary.php +│ ├── ProviderConnectionTargetScopeDescriptor.php +│ ├── ProviderConnectionTargetScopeNormalizer.php +│ └── ProviderIdentityContextMetadata.php +├── bootstrap/ +│ └── providers.php +└── config/ + └── provider_boundaries.php +``` + +**Structure decision**: keep the documentation package self-contained under `specs/281-provider-connection-scope/`; later implementation should update the existing provider resolution, summary, onboarding, and operation-start seams in place instead of introducing a parallel provider-contract subsystem. + +## Complexity Tracking + +No constitution violation or bloat exception is introduced by the plan. The slice removes Microsoft-shaped leakage from existing platform-core seams and adds no new persistence, abstraction family, taxonomy, or framework. + +## Proportionality Review + +- **Current operator problem**: the platform-core provider boundary still forces operators, audit consumers, and operation-run context to rely on Microsoft-shaped identity and scope fields even though provider connections are already modeled as managed-environment-scoped records. +- **Existing structure is insufficient because**: the current repo already has target-scope and summary helpers, but the remaining shared field names and run-context keys still encode Microsoft semantics as generic truth. +- **Narrowest correct implementation**: reuse the existing descriptor, normalizer, identity result, resource summary, onboarding readiness, and start-gate seams while replacing the Microsoft-shaped shared contract fields with neutral `target_scope` and effective-client-identity outputs. +- **Ownership cost created**: focused contract, summary, and proof updates across the listed provider seams and their tests. +- **Alternative intentionally rejected**: a new provider-profile table, provider registry, capability engine, or broader multi-provider identity framework, because none are required by current-release truth. +- **Release truth**: current-release truth; this is a bounded extraction of an already-verified hotspot, not future-provider platform preparation. diff --git a/specs/281-provider-connection-scope/quickstart.md b/specs/281-provider-connection-scope/quickstart.md new file mode 100644 index 00000000..abd8564c --- /dev/null +++ b/specs/281-provider-connection-scope/quickstart.md @@ -0,0 +1,41 @@ +# Quickstart: Provider Connection Scope & Microsoft Profile Extraction + +## Reviewer Flow + +1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together. +2. Confirm the package stays on reserved slot `281` only and treats Spec `279` as completed context and Spec `280` as adjacent prepared routing work only. +3. Confirm the verified current repo truth: `ProviderConnection` already uses `managed_environment_id`, `ProviderConnectionResource` is already non-globally-searchable with `Create`, `View`, and `Edit` pages, and `config/provider_boundaries.php` already classifies `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` as platform-core seams. +4. Confirm the package does not introduce a provider-profile table, registry, capability engine, or any other speculative multi-provider framework. +5. Confirm the canonical shared target-scope contract is explicit and unchanged across artifacts: `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`. +6. Confirm the shared identity-result contract is explicit and neutral: target scope, effective client identity, credential source, blocked reason, and nested provider-context details; shared `tenantContext` is not treated as the long-term platform-core field name. +7. Confirm the planned `OperationRun` context rewrite is explicit: shared `target_scope` becomes the neutral descriptor shape and provider-specific Microsoft detail moves to nested provider context or profile metadata rather than staying at `target_scope.entra_tenant_id`. +8. Confirm `ProviderConnectionSurfaceSummary` remains the single summary contract for `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries. +9. Confirm Filament guardrails remain explicit: Filament v5 stays on Livewire v4, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive provider-connection mutations stay confirmation-protected and server-authorized, and asset strategy remains unchanged. +10. Confirm no application implementation, test execution, or non-spec artifact modification is included in this prep package. + +## Planned Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php) + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php) + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent) +``` + +## Review Questions + +- Does the package stay bounded to contract extraction across the current provider seams rather than drifting into a provider framework or profile table? +- Does the package explicitly avoid reintroducing the stale `provider_connections.tenant_id` move? +- Does the shared `target_scope` schema stay neutral everywhere in the package instead of carrying `entra_tenant_id` as shared truth? +- Does the identity-result contract make effective client identity and credential source explicit while keeping provider-specific Microsoft detail nested? +- Do provider-connections, onboarding, and related-context summaries all reuse one shared summary adapter? +- Does the start-gate contract clearly replace shared `target_scope.entra_tenant_id` with a neutral `target_scope` object plus nested provider context? +- Does `ProviderConnectionResource` stay non-globally-searchable while preserving `View` and `Edit` page destinations and the current confirmation-protected actions? +- Does the package keep Filament on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, and avoid new asset or deployment steps? +- Do Specs `282` through `287` remain explicitly deferred rather than silently absorbed? + +## Notes + +- This prep package changes only planning artifacts under `specs/281-provider-connection-scope/`. +- No application implementation, tests, or runtime validation were executed while preparing the package. \ No newline at end of file diff --git a/specs/281-provider-connection-scope/research.md b/specs/281-provider-connection-scope/research.md new file mode 100644 index 00000000..3097d045 --- /dev/null +++ b/specs/281-provider-connection-scope/research.md @@ -0,0 +1,97 @@ +# Research: Provider Connection Scope & Microsoft Profile Extraction + +**Date**: 2026-05-07 +**Branch**: `281-provider-connection-scope` + +## Decision 1: Keep `281` as a contract-extraction slice, not a new provider framework + +- **Decision**: limit the slice to provider-neutral target-scope and identity contract extraction across the existing `ProviderConnection`, resolver, summary, onboarding, and start-gate seams. +- **Rationale**: current repo truth already contains the right seam inventory: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderIdentityResolution`, and `ProviderOperationStartGate`. The problem is the Microsoft-shaped payload still flowing through them, not the absence of a framework. +- **Alternatives considered**: + - Introduce a provider registry or capability framework: rejected because the feature has only one current provider and the constitution explicitly prefers bounded extraction over speculative multi-provider machinery. + - Add a generic provider profile subsystem first: rejected because the current release does not need new persistence to describe provider profile detail. + +## Decision 2: Treat `ProviderConnection` as the unchanged persisted source of truth + +- **Decision**: `ProviderConnection` stays the only persisted binding record for this slice, anchored by `workspace_id` plus `managed_environment_id`. No `tenant_id` migration work and no provider-profile table are added. +- **Rationale**: `ProviderConnection` already belongs to `ManagedEnvironment` via `managed_environment_id`, and the raw candidate wording about replacing `tenant_id` is stale. The remaining defect is contract shaping, not relationship ownership. +- **Alternatives considered**: + - Re-open the old `tenant_id` to `managed_environment_id` candidate: rejected because repo truth already completed that move. + - Add a separate provider-profile entity: rejected because provider profile detail is still derived from the current connection and identity-resolution path. + +- **Documented candidate deviation**: the raw candidate also proposed a new shared field family around `provider_key`, `external_account_id`, `provider_metadata`, and explicit run-context workspace/environment keys. In current repo truth, `provider`, `workspace_id`, and `managed_environment_id` already exist, so this package narrows the shared contract to `target_scope`, `effective_client_identity`, nested `provider_context`, and existing provider-owned metadata instead of inventing new top-level persisted fields. + +## Decision 3: Make `ProviderConnectionTargetScopeDescriptor` the canonical shared `target_scope` contract + +- **Decision**: standardize every shared target-scope output on `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text` instead of any Microsoft-named top-level keys. +- **Rationale**: the descriptor already exists and already exposes the right field names. What is missing is consistent reuse across resolver, audit, UI summary, onboarding, and run-context seams. +- **Alternatives considered**: + - Keep `target_scope.entra_tenant_id` alongside the neutral fields: rejected because pre-production lean doctrine favors canonical replacement over shared compatibility aliases. + - Add a second DTO or presenter for neutral naming: rejected because the existing descriptor already covers the need. + +## Decision 4: Reshape the shared identity result around effective client identity and nested provider context + +- **Decision**: keep `ProviderIdentityResolution` as the single shared identity-result object, but move its planned contract center of gravity to `target_scope`, `effective_client_identity`, `blocked_reason`, and nested `provider_context` rather than shared `tenantContext` naming. +- **Rationale**: the current implementation already carries these concepts as `targetScope`, `effectiveClientId`, `credentialSource`, and contextual detail arrays. The shared leak is the `tenantContext` field name and its downstream use as the primary field in platform-core seams, so the package standardizes the planned artifact language on `target_scope`, `effective_client_identity`, and `provider_context`. +- **Alternatives considered**: + - Leave `tenantContext` as the canonical shared field and just add neutral labels in UI: rejected because the identity contract would remain Microsoft-shaped below the UI. + - Split dedicated and platform identities into new separate result types: rejected because one existing result object already supports both paths. + +## Decision 5: Keep Microsoft-specific detail inside provider-owned profile or context metadata + +- **Decision**: preserve Microsoft tenant ID, authority tenant, redirect URI, consent URL shaping, and Graph runtime options only inside provider-owned nested metadata or provider-owned consumer seams such as `ProviderIdentityContextMetadata`, `AdminConsentUrlFactory`, and `ProviderGateway`. +- **Rationale**: the boundary catalog already marks identity resolution and operation start as platform-core while runtime transport and consent URL shaping remain provider-owned. The narrowest fix is to push Microsoft semantics outward, not to pretend they disappear. +- **Alternatives considered**: + - Strip Microsoft detail from all outputs entirely: rejected because support, consent, and troubleshooting still need those fields. + - Keep Microsoft detail as top-level fields in shared summaries: rejected because that keeps the platform-core contract Microsoft-shaped. + +## Decision 6: Reuse `ProviderConnectionSurfaceSummary` as the only summary adapter + +- **Decision**: keep `ProviderConnectionSurfaceSummary` as the one summary contract reused by `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context summaries. +- **Rationale**: the wizard already calls `ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary()`, and the resource already delegates summary rendering there. Reusing the same adapter is the narrowest way to remove duplicate truth. +- **Alternatives considered**: + - Let onboarding and provider-connections each compute their own neutral labels: rejected because it would create the second identity story this feature is meant to remove. + - Put raw `entra_tenant_id` back into onboarding for clarity: rejected because it would reintroduce a Microsoft-only summary path. + +## Decision 7: Rewrite provider-operation start context to use neutral shared scope fields + +- **Decision**: `ProviderOperationStartGate` should emit the neutral `target_scope` descriptor in started and blocked run context, with any Microsoft-specific detail nested under provider-owned provider-context metadata. +- **Rationale**: the start gate is one of the explicit boundary hotspots in `config/provider_boundaries.php`, and it still writes `target_scope.entra_tenant_id` directly today. Later artifact and taxonomy work would inherit that leak if it stays. +- **Alternatives considered**: + - Leave the start-gate context alone and only update UI summaries: rejected because `OperationRun` context is shared operational truth. + - Add neutral fields beside `entra_tenant_id`: rejected because the feature is pre-production and should replace the shared shape rather than dual-write it. + +## Decision 8: Keep credential validation neutral at the platform-core seam + +- **Decision**: `CredentialManager` stays the existing credential access seam, but its scope-validation rule and error wording should align to the normalized target-scope identifier rather than a Microsoft-shaped mismatch message. +- **Rationale**: `CredentialManager` is already inside the shared provider identity path and currently compares a payload scope assertion against `connection->entra_tenant_id`. That is acceptable as an implementation detail, but not as the platform-core contract wording. +- **Alternatives considered**: + - Ignore the message mismatch and leave it Microsoft-shaped: rejected because it leaks provider semantics back into the shared identity failure path. + - Change provider-credential persistence shape now: rejected because no new persistence is justified for this slice. + +## Decision 9: Keep Filament surface behavior and deployment strategy unchanged + +- **Decision**: `ProviderConnectionResource` remains non-globally-searchable, keeps `View` and `Edit` pages, preserves the current confirmation-protected mutations, and adds no new asset registration. +- **Rationale**: the feature changes summary and contract shaping only. The current resource already satisfies the required Filament action and search guardrails. +- **Alternatives considered**: + - Enable global search for provider connections while touching the resource: rejected because the feature does not need it and the spec explicitly keeps it out of scope. + - Add new UI chrome or asset bundles for provider profile detail: rejected because the existing resource and wizard can carry the nested disclosure. + +## Decision 10: Prove the slice with focused feature coverage and one browser smoke + +- **Decision**: use focused provider feature tests, one Filament/browser smoke covering provider-connections plus onboarding, and keep proof commands identical across spec, plan, and quickstart. +- **Rationale**: this slice changes shared contract truth across several PHP seams and two operator-facing consumers, but it does not justify a new heavy-governance family or broad browser matrix. +- **Alternatives considered**: + - Feature tests only: rejected because the live resource plus onboarding continuity is part of the user-visible contract. + - Broad browser or smoke expansion across unrelated provider pages: rejected because it would create unnecessary suite cost. + +## Final Research Outcome + +- `ProviderConnection` and `ProviderCredential` persistence stay unchanged. +- The canonical shared `target_scope` contract is the existing descriptor shape, not `entra_tenant_id`. +- The canonical shared identity result is the existing `ProviderIdentityResolution` seam, but its planned contract must stop centering `tenantContext` as shared truth and instead standardize on `target_scope`, `effective_client_identity`, and nested `provider_context`. +- Provider-specific Microsoft details remain available through `ProviderIdentityContextMetadata` and provider-owned consumers only. +- `ProviderConnectionSurfaceSummary` stays the one summary adapter for provider-connections, onboarding, and related context. +- `ProviderOperationStartGate` becomes the critical contract rewrite point for neutral run context. +- `ProviderConnectionResource` remains non-globally-searchable with confirmation-protected mutations intact. +- The narrowest honest proof is the feature suite already named in the spec, one browser smoke, and no new runtime abstractions. \ No newline at end of file diff --git a/specs/281-provider-connection-scope/spec.md b/specs/281-provider-connection-scope/spec.md new file mode 100644 index 00000000..7cc2f010 --- /dev/null +++ b/specs/281-provider-connection-scope/spec.md @@ -0,0 +1,403 @@ +# Feature Specification: Provider Connection, Provider Scope & Microsoft Profile Extraction + +**Feature Branch**: `281-provider-connection-scope` +**Created**: 2026-05-07 +**Status**: Ready +**Input**: User description: "Work only in /Users/ahmeddarrazi/Documents/projects/wt-plattform on the already-created feature branch 281-provider-connection-scope. This is preparation-only work. Do not modify application/runtime code, tests, migrations, models, routes, views, or any non-spec artifacts. + +Task: fill /Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/281-provider-connection-scope/spec.md as the implementation-ready spec for candidate `281 - Provider Connection, Provider Scope & Microsoft Profile Extraction` from docs/product/spec-candidates.md and docs/product/roadmap.md. + +Required repo truth and constraints: +- Use the existing style and depth of specs/280-workspace-tenancy-environment-routing/spec.md. +- Current branch/worktree safety is already checked and safe; stay on 281-provider-connection-scope. +- 279 is completed historical context; 280 is an active/prepared adjacent spec but not the target. Do not edit any existing completed or adjacent spec packages. +- The raw candidate text is partially stale: provider_connections already use managed_environment_id in repo truth, so do not claim 281 still needs the tenant_id -> managed_environment_id move. Document that as a candidate deviation. +- Current repo seams show the real remaining 281 work lives around: + - apps/platform/app/Models/ProviderConnection.php + - apps/platform/config/provider_boundaries.php + - apps/platform/app/Services/Providers/ProviderConnectionResolver.php + - apps/platform/app/Services/Providers/ProviderIdentityResolver.php + - apps/platform/app/Services/Providers/ProviderIdentityResolution.php + - apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php + - apps/platform/app/Services/Providers/ProviderOperationStartGate.php + - apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php + - apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php + - apps/platform/app/Services/Providers/CredentialManager.php +- Repo truth currently still hardcodes Microsoft-shaped identity/scope in platform-core seams via ProviderConnection::entra_tenant_id, tenantContext strings, and OperationRun context target_scope.entra_tenant_id. +- There is already provider-boundary groundwork and target-scope normalization, but no dedicated provider-profile model/table yet. +- Follow constitution rules: prefer bounded extraction over speculative multi-provider frameworks; no new persisted truth unless justified; provider-owned vs platform-core seams must be classified explicitly. +- Keep the spec narrow and implementation-ready. Prefer the smallest honest slice that normalizes provider-neutral target-scope and identity contracts while confining Microsoft-specific profile semantics to provider-owned metadata/profile seams. +- Preserve TenantPilot terminology where the repo still uses it, but make the platform-core/provider-boundary reasoning explicit. +- Include the mandatory spec-template sections, candidate gate summary, completed-spec guardrail result, assumptions, risks, in-scope/non-goals, and deferred adjacent candidates. +- Include explicit Candidate Selection Gate reasoning for why 281 is chosen now and why 282-287 are deferred. +- Include the explicit agent-output contract details relevant at spec level: Livewire v4 compliance note, provider registration location bootstrap/providers.php, globally searchable resource note if any touched resource is mentioned, destructive action confirmation note if any UI mutation is referenced, asset strategy note, testing plan summary. +- Make the final spec concrete enough that a later plan/tasks pass can generate research/data-model/quickstart/contracts without guesswork." + +## Spec Candidate Check + +- **Problem**: Repo truth already moved `ProviderConnection` onto `managed_environment_id`, but shared platform-core seams still treat Microsoft tenant identity as the generic provider-scope truth. `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionTargetScopeNormalizer`, `ProviderOperationStartGate`, and related operator summaries still lean on `entra_tenant_id`, `tenantContext`, and `target_scope.entra_tenant_id` as if they were neutral platform contracts. +- **Today's failure**: Operators can reach provider connections and start provider-backed work, but the shared connection identity story still depends on Microsoft-shaped field names. Onboarding readiness, provider connection summaries, audit metadata, and `OperationRun` context can all describe the same connection differently, which makes the cutover pack look provider-neutral in naming while still being Microsoft-shaped in the seams that actually decide behavior. +- **User-visible improvement**: Operators get one consistent provider-connection and target-scope story across provider connections, managed-environment detail summaries, onboarding readiness, and provider-operation follow-up. Microsoft consent, portal, and tenant-profile details remain available, but only inside clearly provider-owned profile/context disclosure instead of as the primary shared vocabulary. +- **Smallest enterprise-capable version**: Reuse the current `ProviderConnection` record, `ProviderConnectionResource`, onboarding wizard, target-scope normalizer, identity resolution path, and provider-operation start gate; standardize provider-neutral target-scope and identity outputs across those seams; move Microsoft-specific profile semantics into provider-owned metadata/profile sections; and stop writing `target_scope.entra_tenant_id` as the shared run-context contract. No new table, registry, package engine, or multi-provider framework is introduced. +- **Explicit non-goals**: No `tenant_id` -> `managed_environment_id` migration on `provider_connections`; no dedicated provider-profile table; no capability registry; no provider-neutral artifact taxonomy; no governance-artifact retargeting; no workspace-RBAC redesign; no broader copy/localization neutralization; no new provider implementation; no legacy fallback or backfill. +- **Permanent complexity imported**: One refined provider-neutral target-scope and identity contract across existing seams, one provider-owned Microsoft profile disclosure pattern on existing surfaces, and focused feature/browser coverage. No new persisted truth, registry, or extension framework is added. +- **Why now**: Spec `279` already completed the core managed-environment noun change, and Spec `280` prepares the workspace-first routing shell. The next verified blocker in the reserved cutover pack is the provider boundary itself: until `281` lands, later slices such as artifact retargeting and provider-neutral taxonomy would still inherit Microsoft-shaped shared connection and run context. +- **Why not local**: A label-only or page-local fix would leave the deciding seams untouched. `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, and audit/run context are the real control points; if they stay Microsoft-shaped, the platform core remains Microsoft-shaped no matter how neutral the page copy becomes. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Shared provider/platform boundary, provider-operation execution context, and temptation to introduce new persistence or a speculative provider framework. Defense: this slice explicitly rejects a new table, registry, or capability framework and instead reuses the existing target-scope, identity, resource, and onboarding seams as the narrowest current-release extraction path. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/provider-connections` + - `/admin/provider-connections/create` with `managed_environment_id` query context + - `/admin/provider-connections/{record}` + - `/admin/provider-connections/{record}/edit` + - named onboarding routes `admin.onboarding` and `admin.onboarding.draft` + - managed-environment detail and related-context entry points that deep-link into provider connections under the surviving admin panel +- **Data Ownership**: + - `ProviderConnection` remains the existing workspace-owned, managed-environment-scoped provider binding record + - `ProviderCredential` remains the existing provider-owned secret record attached to one `ProviderConnection` + - Microsoft-specific profile semantics remain provider-owned metadata/profile detail; this slice does not introduce a dedicated provider-profile table or other new persisted truth + - `OperationRun` remains execution truth and records provider-neutral target-scope fields plus provider-specific nested context only where provider-specific follow-up truly needs it +- **RBAC**: + - workspace membership remains the first isolation boundary + - managed-environment access remains the second isolation boundary + - `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, and `PROVIDER_RUN` remain the capability gates for the existing provider-connection surfaces + - non-members stay `404`, and in-scope actors missing a capability stay `403` + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, list/detail summaries, onboarding readiness messaging, provider-operation start feedback, audit metadata, and provider-specific consent/navigation links +- **Systems touched**: `ProviderConnectionResource`, `ProviderConnectionSurfaceSummary`, `TenantResource` related-context/provider summary entries, `ManagedTenantOnboardingWizard`, `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionTargetScopeDescriptor`, `CredentialManager`, `RequiredPermissionsLinks`, `AdminConsentUrlFactory`, and `config/provider_boundaries.php` +- **Existing pattern(s) to extend**: the current target-scope descriptor/normalizer path, the current provider-connection resource action family, the current onboarding readiness/provider summary path, and the shared provider-operation start gate/presenter contract +- **Shared contract / presenter / builder / renderer to reuse**: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderIdentityResolution`, `ProviderConnectionSurfaceSummary`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, and the existing `ProviderConnectionResource` action group/view model helpers +- **Why the existing shared path is sufficient or insufficient**: the repo already has the right shared seams. What is insufficient is not the absence of a framework, but the Microsoft-shaped payload and naming that still flows through those seams. Extending the existing path is sufficient; creating a new provider framework is not. +- **Allowed deviation and why**: one bounded deviation is allowed: Microsoft-specific tenant-profile, consent, portal, and required-permissions details may remain inside a clearly provider-owned profile/context block or nested provider metadata where current support and consent workflows need them. They must not become the shared label set for target scope, provider identity, or run context. +- **Consistency impact**: provider-connection list/detail pages, managed-environment summaries, onboarding readiness, audit metadata, and provider-operation follow-up must all describe the same connection with the same shared target-scope summary before any provider-specific detail is shown. +- **Review focus**: reviewers must verify that the shared target-scope/identity contract is reused instead of a parallel helper, that Microsoft detail is nested inside provider-owned sections only, that the provider-connection resource and onboarding wizard show the same summary contract, and that no new table or registry quietly appears. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, start/block/link semantics only +- **Shared OperationRun UX contract/layer reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `OperationRunLinks`, and the existing `OperationRunService` lifecycle path +- **Delegated start/completion UX behaviors**: current shared start-gate and presenter paths remain responsible for queued/block/dedupe messaging, run links, and lifecycle semantics; this slice only changes the connection identity and target-scope data they carry +- **Local surface-owned behavior that remains**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` continue to own only initiation inputs, connection selection, and current-surface follow-up links +- **Queued DB-notification policy**: `N/A` - unchanged shared policy +- **Terminal notification path**: existing central lifecycle mechanism +- **Exception required?**: none + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: + - platform-core: `provider.connection_resolution`, `provider.identity_resolution`, `provider.operation_start_gate` + - provider-owned: Microsoft consent URL shaping, Microsoft portal/profile details, and Microsoft Graph runtime option mapping + - mixed UI bridge: provider-connection summaries, managed-environment related context, and onboarding readiness that expose shared target-scope truth plus provider-specific follow-up detail +- **Neutral platform terms preserved or introduced**: `provider connection`, `target scope`, `scope kind`, `scope identifier`, `scope display name`, `effective client identity`, `credential source`, `provider profile`, `provider context`, `workspace`, and `managed environment` +- **Provider-specific semantics retained and why**: Microsoft tenant directory ID, authority tenant, admin-consent callback/URL, required-permissions guidance, Graph client identity, domains, and portal links remain necessary for current consent and troubleshooting workflows. They stay provider-owned and must not be promoted into platform-core truth. +- **Why this does not deepen provider coupling accidentally**: shared platform-core seams move toward neutral `target_scope` and identity language, while Microsoft-specific profile detail is explicitly nested under provider-owned metadata/profile seams. The feature removes a Microsoft-shaped hotspot instead of creating a new generalized provider platform. +- **Follow-up path**: Spec `282` for governance-artifact retargeting consumers, Spec `283` for capability registry follow-through, Spec `284` for provider-neutral artifact taxonomy, Spec `285` for workspace-first RBAC follow-through, Spec `286` for broader operator-copy neutralization, and Spec `287` for the cutover quality-gate pack + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Provider connections resource family (`List`, `View`, `Create`, `Edit`) | yes | Native Filament resource plus shared action/presenter primitives | list/detail summary, consent/action group, provider-operation feedback | page, table, detail, modal, Livewire state | no | Reuses the existing resource, action surface declaration, and view/edit pages; the slice changes shared connection/profile semantics, not the route family | +| Managed-environment detail provider connection summary and related-context entry | yes | Native Filament resource detail plus existing related-context pattern | related navigation, summary chips, environment-scoped follow-up | detail, link state | no | Summary only; no new detail surface or action family is introduced | +| Managed-environment onboarding provider-connection step | yes | Native Filament custom wizard using existing onboarding shell and shared provider summaries | workflow selection, readiness summary, supporting links | page, wizard, session, Livewire state | no | Reuses the onboarding wizard; no second onboarding framework or connection picker is introduced | + +## Decision-First Surface Role + +| 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 | +|---|---|---|---|---|---|---|---| +| Provider connections resource family | Primary Decision Surface | Operator decides whether one provider connection is usable, default-worthy, or ready to run provider work | managed environment, provider, target scope, lifecycle, consent, verification | credential source, migration-review detail, provider-specific profile data, last error, and run follow-up | Primary because this is the configuration and execution surface where the operator chooses the active provider binding | Matches the existing integrations/settings workflow instead of inventing another workbench | Removes the need to jump between tenant detail, onboarding, and operations just to identify connection scope | +| Managed-environment detail provider connection summary and related-context entry | Secondary Context Surface | Operator confirms an environment has a usable provider connection and decides whether to drill in | high-level provider-connection summary and direct link | full connection detail remains on the provider-connections resource | Secondary because it advertises context and next step rather than owning the decision itself | Keeps managed-environment overview calm while still exposing the next relevant integration step | Reduces navigation search and duplicate summary cards | +| Managed-environment onboarding provider-connection step | Primary Decision Surface | Operator chooses or creates the connection that will unblock onboarding readiness | selected connection, target scope summary, consent/readiness state, next step | provider-specific profile detail, verification detail, and supporting links | Primary because onboarding cannot continue safely until the correct connection is chosen and understood | Matches the current onboarding workflow and keeps connection readiness inside it | Prevents operators from reconstructing connection identity from raw IDs or separate pages | + +## Audience-Aware Disclosure + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Provider connections resource family | operator-MSP, support-platform | provider, target scope, lifecycle, consent, verification, and current default-connection truth | credential source, migration review, blocked reason, recent operation follow-up | Microsoft-specific profile identifiers, portal links, required-permissions detail, and other provider-owned troubleshooting context | `Open`, `Check connection`, or one existing primary provider action depending on surface | dedicated credential secrets, provider-owned raw detail, and support-only context stay gated or nested | target scope is stated once in the shared summary and not re-explained in every provider-specific subsection | +| Managed-environment detail provider connection summary and related-context entry | operator-MSP, support-platform | whether a connection exists, its high-level status, and where to go next | minimal summary only | none on this surface | `Open provider connections` | provider-specific profile detail stays on the provider-connections resource | the managed-environment page advertises the next step without duplicating the full connection detail card | +| Managed-environment onboarding provider-connection step | operator-MSP, support-platform | selected connection, target scope summary, consent/readiness status, and the next unblock action | verification result, blocked reason, supporting links, and run continuity | provider-specific profile detail stays behind provider-owned disclosure or support links | `Select connection`, `Create connection`, or `Continue` depending on current state | raw provider profile detail and credential data stay hidden | the wizard uses the same target-scope summary as the resource instead of inventing a second identity story | + +## UI/UX Surface Classification + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections resource family | List / Detail / Integrations | CRUD / List-first Resource | Open one connection, verify it, or run provider work | clickable row to the View page | required | grouped under `More` on list/view surfaces | grouped under `More`, server-authorized, confirmation-required where mutating or dangerous | `/admin/provider-connections` | `/admin/provider-connections/{record}` and `/admin/provider-connections/{record}/edit` | managed-environment filter, workspace context, provider, target scope | Provider connection | target scope and connection health | none | +| Managed-environment detail provider connection summary and related-context entry | Detail / Related Context / Navigation | Environment detail follow-up entry | Open provider connections for the current managed environment | explicit related-context `Open` link | forbidden | none beyond the explicit open affordance | none | managed-environment detail surface with provider-connections deep link | `/admin/provider-connections?managed_environment_id={environment}` | current workspace and current managed environment | Provider connections | whether the environment is wired to a usable provider connection | none | +| Managed-environment onboarding provider-connection step | Workflow Hub / Wizard / Readiness | Workflow-step selector | Select or create the correct provider connection and continue onboarding | in-step selection and explicit create or manage actions | forbidden | supporting links and provider follow-up remain secondary | none added by this slice | named onboarding routes `admin.onboarding` and `admin.onboarding.draft` | same wizard step or draft route | current workspace, current managed environment, selected provider connection | Provider connection | selected connection and readiness state | none | + +## Operator Surface Contract + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections resource family | Workspace operator | Decide whether one provider connection is the right and healthy binding for this managed environment | List/detail resource | Is this the right provider connection and is it safe to use right now? | managed environment, provider, target scope, lifecycle, consent, verification, default state | migration review, last error, credential source, provider-specific profile detail, run follow-up | lifecycle, consent, verification, migration-review | `TenantPilot only` for connection metadata and credentials, `Microsoft tenant` only when admin consent or provider execution is triggered | Open, Check connection, Inventory sync, Compliance snapshot, Edit | Set default, enable or rotate dedicated credentials, revert or delete credentials, enable or disable connection | +| Managed-environment detail provider connection summary and related-context entry | Workspace operator | Decide whether to inspect or fix provider connection state from the environment overview | Related-context summary | Do I need to drill into provider connections from this environment now? | high-level connection presence and status plus direct link | none beyond brief summary copy | lifecycle, verification | none | Open provider connections | none | +| Managed-environment onboarding provider-connection step | Workspace operator | Choose the connection that will unblock onboarding and later provider operations | Wizard step | Which provider connection should this onboarding flow use, and is it ready? | selected connection, target scope summary, readiness and consent state, next step | blocked reason, supporting verification links, provider-specific profile detail on demand | readiness, consent, verification | `TenantPilot only` for selection and onboarding draft state; provider execution remains separate | Select connection, Create connection, Continue | none added by this slice | + +## Proportionality Review + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: the platform-core provider boundary still forces operators and downstream run/audit consumers to rely on Microsoft-specific scope identity even though provider connections are already modeled as managed-environment-bound records. +- **Existing structure is insufficient because**: the current structure already has shared target-scope and identity helpers, but those helpers still emit Microsoft-shaped fields and context. Leaving them untouched would keep the real platform-core contract Microsoft-specific even if page copy becomes more neutral. +- **Narrowest correct implementation**: extend the existing target-scope descriptor, identity resolution, provider-connection summary, onboarding readiness, and provider-operation start seams so they emit one neutral shared contract while keeping Microsoft profile detail nested under provider-owned metadata/profile blocks. +- **Ownership cost**: focused updates across provider resolution, run context, audit metadata, provider-connection resource summaries, onboarding readiness, and their tests. The cost stays bounded because no new table, registry, or framework is introduced. +- **Alternative intentionally rejected**: a new provider-profile table, a generic provider profile registry, or a broader multi-provider identity framework. Those options add structure without a current-release source-of-truth need and violate the constitution's bounded-extraction bias. +- **Release truth**: current-release truth; this slice closes an active platform-core/provider-boundary mismatch rather than preparing speculative future providers. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Feature, Browser +- **Validation lane(s)**: fast-feedback, confidence, browser +- **Why this classification and these lanes are sufficient**: feature coverage is the narrowest honest proof for provider-boundary contract changes across target-scope normalization, identity resolution, provider-operation start context, resource semantics, and onboarding readiness. One browser smoke is justified because the operator-facing resource and onboarding flow must show the same summary and action semantics in the real shell. +- **New or expanded test families**: one provider target-scope or identity feature family, one provider-operation start-gate context family, one provider-connection Filament resource behavior family, one onboarding provider-connection readiness family, and one narrow browser smoke covering the provider-connections plus onboarding path +- **Fixture / helper cost impact**: moderate. Tests need workspace, managed environment, provider connection, optional provider credential, and focused run fixtures. No new global defaults, provider registries, or browser-wide setup should become implicit. +- **Heavy-family visibility / justification**: one browser smoke only. No heavy-governance family is justified by this slice. +- **Special surface test profile**: standard-native-filament, workflow-hub, global-context-shell +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the shared contract shifts; one browser smoke is required to prove that provider-connections and onboarding show the same target-scope summary and action affordances on real surfaces. +- **Reviewer handoff**: reviewers must verify that Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable while continuing to offer View and Edit pages, touched destructive actions still use `->action(...)` plus `->requiresConfirmation()` and server authorization, no new asset registration appears, and the planned tests prove the shared neutral target-scope contract instead of page-local wording only. +- **Budget / baseline / trend impact**: moderate feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- document the verified candidate deviation that `provider_connections` already use `managed_environment_id` +- normalize the shared provider-connection target-scope contract across connection resolution, identity resolution, audit metadata, resource summaries, onboarding readiness, and provider-operation start context +- normalize the shared provider-identity contract so platform-core seams talk about effective client identity, credential source, and target scope rather than Microsoft tenant context as generic truth +- update provider-operation start context so the shared `target_scope` payload is provider-neutral and no longer relies on `target_scope.entra_tenant_id` +- keep Microsoft-specific profile semantics in provider-owned metadata or profile detail, provider-specific links, and support-oriented disclosure +- align `ProviderConnectionResource`, managed-environment related context, and onboarding readiness on one shared target-scope summary contract +- keep current provider-connection actions, consent links, and capability boundaries intact while clarifying which ones are provider-owned versus platform-core +- keep provider-boundary seam classification explicit in `config/provider_boundaries.php` and related review proof + +### Non-Goals + +- repeating the already-completed `tenant_id` -> `managed_environment_id` move on `provider_connections` +- introducing a dedicated provider-profile table or any other new persisted profile truth +- introducing a provider capability registry, package engine, provider-neutral artifact taxonomy, or governance-artifact retargeting +- widening the slice into workspace-first RBAC redesign, broader copy or localization neutralization, or cutover quality-gate work reserved for Specs `285`-`287` +- enabling global search for `ProviderConnectionResource` +- adding a second provider implementation, compatibility shim, backfill path, or speculative multi-provider identity framework + +## Assumptions + +- Spec `279` already completed the managed-environment core cutover and is prerequisite context only. +- Spec `280` already defines the adjacent workspace-first routing shell and remains separate prepared context; `281` must not absorb its route-cutover work. +- `ProviderConnection` already remains anchored by `workspace_id` plus `managed_environment_id`, and the 281 problem is contract extraction rather than relationship migration. +- The existing `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionTargetScopeDescriptor`, `ProviderIdentityResolution`, `ProviderConnectionSurfaceSummary`, and provider-connection resource or onboarding surfaces are the correct extension points for this slice. +- Microsoft remains the only implemented provider today, but platform-core seams touched here must still use neutral terms so later provider work is not blocked. +- `ProviderConnectionResource` remains non-globally-searchable; touched searchable resources such as `TenantResource` keep their existing valid view destinations. + +## Risks + +- downstream run, audit, or support readers may still expect `target_scope.entra_tenant_id` even after the start gate writes the neutral shape +- some operator surfaces may migrate to the new target-scope summary while others still read raw `entra_tenant_id`, leaving the same connection described differently +- dedicated credential validation and blocked reason messaging may stay Microsoft-shaped if `CredentialManager` and identity-resolution seams are not updated together +- the slice could sprawl into capability-registry, taxonomy, or copy-neutralization work if reviewers allow 283, 284, or 286 concerns to enter the implementation + +## Candidate Selection Gate Summary + +- **Selected candidate**: `281 - Provider Connection, Provider Scope & Microsoft Profile Extraction` +- **Source locations**: + - `docs/product/spec-candidates.md` under the reserved workspace-first or provider-neutral cutover pack + - `docs/product/roadmap.md` under the same cutover ordering +- **Why selected now**: after the completed `279` core cutover and the prepared `280` routing cutover, the next verified blocker is the provider boundary itself. The repo already has target-scope groundwork, but shared provider connection and run identity still leak Microsoft semantics in the platform core. +- **Why close alternatives were deferred**: + - `282` depends on `281` because governance-artifact retargeting should inherit provider-neutral connection and run scope instead of another Microsoft-shaped shared contract + - `283` is capability-registry follow-through and should not be pulled into this slice while the connection or profile contract is still being normalized + - `284` is a broader artifact-source taxonomy and belongs after the provider-connection and run-scope boundary is neutralized + - `285` is workspace-first RBAC follow-through and can remain on the current capability model while `281` closes the connection or profile hotspot + - `286` is broader operator-copy and localization neutralization and should follow the concrete provider-boundary extraction rather than precede it + - `287` is the cutover quality-gate pack and should harden the finished slices instead of expanding `281` +- **Smallest viable implementation slice**: one neutral shared target-scope and identity contract across existing provider seams plus provider-owned Microsoft profile disclosure on the current resource and onboarding surfaces +- **Documented deviations from raw candidate wording**: + - the raw candidate still mentions replacing `provider_connections.tenant_id` with `provider_connections.managed_environment_id`, but repo truth already completed that move + - the raw candidate also proposed a new shared field family around `provider_key`, `external_account_id`, `provider_metadata`, and explicit run-context workspace/environment keys; this package narrows that to existing repo truth by keeping `provider`, `workspace_id`, and `managed_environment_id` where they already exist, using the canonical shared `target_scope` plus `effective_client_identity`, and confining provider-specific identifiers or profile detail to nested `provider_context` and existing provider-owned metadata + +## Completed-Spec Guardrail Result + +- `specs/279-workspace-managed-environment-core/spec.md` already exists with `Status: Ready with approved feature-local exception` and remains historical prerequisite context only +- `specs/280-workspace-tenancy-environment-routing/spec.md` already exists with `Status: Ready` and remains an adjacent prepared package only; its route-cutover work must not be absorbed into `281` +- the target package `specs/281-provider-connection-scope/spec.md` existed only as the raw template before this update and is the sole spec artifact edited in this package + +## Deferred Adjacent Candidates + +- `282 - Governance Artifact Retargeting to ManagedEnvironment` +- `283 - Provider Capability Registry v1` +- `284 - Provider-neutral Artifact Source Taxonomy v1` +- `285 - Workspace-first RBAC & Environment Access Scoping` +- `286 - UI Copy, IA & Localization Neutralization` +- `287 - Cutover Quality Gates & No-Legacy Enforcement` + +## User Scenarios & Testing + +### User Story 1 - Inspect a provider connection with one neutral target-scope summary (Priority: P1) + +As an operator, I want provider-connection list and detail surfaces to tell me immediately which managed environment and target scope a connection represents without forcing me to interpret a Microsoft-only field name before I can decide whether the connection is usable. + +**Why this priority**: this is the core operator-facing trust problem in the current seam. If the shared summary remains Microsoft-shaped, later provider-neutral cutover work keeps inheriting that drift. + +**Independent Test**: open the provider-connections list and one connection detail page for a managed environment, then confirm the shared summary shows provider, target scope, and health first while Microsoft profile detail stays nested under a provider-owned disclosure. + +**Acceptance Scenarios**: + +1. **Given** a managed environment has a provider connection, **When** the operator opens the provider-connections list or detail page, **Then** the default-visible summary shows provider, target scope, lifecycle, consent, and verification without requiring a Microsoft-only field label to understand the connection identity. +2. **Given** the operator needs provider-specific consent or troubleshooting data, **When** they open the provider-owned profile or context disclosure on the connection detail surface, **Then** Microsoft-specific details appear there without replacing the shared target-scope label set. + +--- + +### User Story 2 - Start provider work without Microsoft-shaped shared run context (Priority: P1) + +As an operator, I want connection checks and provider operations started from provider-connection or onboarding surfaces to record provider-neutral target-scope context so later run follow-up, audit, and support flows do not depend on Microsoft-only keys. + +**Why this priority**: `ProviderOperationStartGate` is one of the verified remaining hotspots, and later artifact and taxonomy work depends on this shared execution context becoming neutral first. + +**Independent Test**: start one provider-backed operation from a provider connection and verify that the resulting run can be identified by provider connection and target scope without relying on `target_scope.entra_tenant_id` as the shared contract. + +**Acceptance Scenarios**: + +1. **Given** an operator starts `Check connection`, `Inventory sync`, or `Compliance snapshot` from a valid provider connection, **When** the run is created, **Then** the resulting run context identifies provider connection and target scope through provider-neutral shared fields, with any Microsoft-specific detail nested under provider-owned context only. +2. **Given** a provider operation is blocked because consent, scope, or credentials are invalid, **When** the blocked result is shown, **Then** the operator still sees a usable target-scope summary and blocked reason without the shared contract collapsing back to Microsoft-only field names. + +--- + +### User Story 3 - See the same connection story in onboarding and provider settings (Priority: P2) + +As an operator, I want the onboarding wizard to describe provider connections with the same target-scope summary used on the provider-connections resource so I do not need to reinterpret the same connection differently just because I am in onboarding. + +**Why this priority**: onboarding is already one of the verified seams reusing provider-connection summaries and audit metadata. Drift here would immediately create a second identity language. + +**Independent Test**: select or create a provider connection from the onboarding wizard and confirm that the displayed target-scope summary matches the one shown on the provider-connections resource for the same record. + +**Acceptance Scenarios**: + +1. **Given** onboarding lists multiple provider connections for the current managed environment, **When** the operator reviews the choices, **Then** each option uses the same target-scope summary contract as the provider-connections list and detail surfaces. +2. **Given** onboarding shows provider verification or supporting links for the selected connection, **When** the operator inspects that state, **Then** the readiness surface uses the same shared target-scope and provider-context labels as the provider-connections resource. + +--- + +### User Story 4 - Jump from managed-environment detail into provider connections without duplicate truth (Priority: P3) + +As an operator, I want the managed-environment detail page to advertise provider-connection state and take me into the provider-connections resource without duplicating the full provider profile summary on the overview page. + +**Why this priority**: this is the boundary between context surfaces and primary decision surfaces. The environment overview should stay calm while still exposing the integration step. + +**Independent Test**: open one managed-environment detail page, use its provider-connections related-context entry, and verify that the overview stays summary-first while the provider-connections resource becomes the primary decision surface. + +**Acceptance Scenarios**: + +1. **Given** a managed environment has an accessible provider connection, **When** the operator uses the provider-connections related-context entry from the environment view, **Then** the destination opens the provider-connections resource scoped to that environment and carries the same shared target-scope summary. +2. **Given** the operator stays on the managed-environment overview page, **When** they read the provider-connection summary there, **Then** the page shows only the minimal connection truth needed to decide whether to drill in and does not duplicate full provider-profile detail. + +### Edge Cases + +- A provider connection with missing or invalid target-scope input must stay blocked explicitly and must not fall back to an implicit Microsoft tenant context. +- An unsupported provider or scope combination must fail as an unsupported binding or invalid connection rather than being normalized into a Microsoft-shaped default. +- A dedicated credential payload whose embedded environment or scope hint no longer matches the connection's normalized target scope must fail validation rather than silently reusing stale Microsoft identity. +- Multiple default provider connections for the same managed environment and provider must remain blocked and clearly diagnosable. +- A provider connection record or onboarding selection outside the current workspace or managed environment must resolve as `404` or unavailable, not as a leaked or silently widened option. +- If provider-specific profile data such as domains or portal links are absent, the shared target-scope summary must still render truthfully without forcing a new persisted profile table into scope. + +## Requirements + +**Constitution alignment (required):** This slice changes provider connection resolution, identity resolution, provider-operation start context, provider-connection admin surfaces, and onboarding readiness. It does not introduce new Microsoft Graph contract registry entries, new provider implementations, or new long-running workflow types. + +**Constitution alignment (PROP-001 / PROV-001 / BLOAT-001):** The slice must stay bounded to current provider-connection and run-context hotspots. No new table, registry, or generic provider framework is justified unless later planning can prove a current-release source-of-truth need. + +**Constitution alignment (XCUT-001 / UI-FIL-001 / DECIDE-001):** The slice must reuse the existing provider-connections resource, existing onboarding wizard, and existing provider-operation start path. It may refine shared summaries and provider-owned detail disclosure, but it must not invent a second provider workbench, a second onboarding picker, or ad-hoc status styling. + +**Constitution alignment (RBAC-UX):** Workspace and managed-environment membership remain the isolation boundaries. Provider manage or run mutations stay server-authorized and confirmation-protected where destructive or high-impact. Navigation-only actions such as `Grant admin consent` stay capability-gated but do not claim confirmation behavior. + +**Constitution alignment (TEST-GOV-001 / OPS-UX-START-001):** Proof must stay bounded to feature tests plus one browser smoke. Operation start behavior must continue to flow through the shared provider-operation gate or presenter path, with the only change being the shared target-scope and provider-context contract. + +### Functional Requirements + +- **FR-001**: The system MUST preserve `ProviderConnection` as the existing workspace-owned, managed-environment-scoped provider binding record and MUST NOT reintroduce `tenant_id` semantics into `provider_connections`. +- **FR-002**: Shared provider-connection target-scope output MUST use provider-neutral fields for provider, scope kind, scope identifier, and scope display name wherever the platform core records or renders connection scope. +- **FR-003**: `ProviderConnectionResolver` and related validation paths MUST treat provider plus managed environment plus normalized target scope as the controlling scope contract, not `entra_tenant_id` as generic platform truth. +- **FR-004**: Shared provider identity resolution MUST separate neutral identity semantics such as effective client identity, credential source, and target scope from provider-specific profile or context detail. +- **FR-005**: `ProviderIdentityResolution` and `PlatformProviderIdentityResolver` MUST stop using `tenantContext` as the canonical shared contract term and MUST confine any remaining Microsoft tenant-context semantics to provider-owned profile or context detail. +- **FR-006**: `CredentialManager` validation MUST align dedicated credentials with the connection's normalized target scope and provider binding rather than treating a Microsoft-specific field as the only source of truth. +- **FR-007**: `ProviderConnectionTargetScopeNormalizer` and `ProviderConnectionTargetScopeDescriptor` MUST continue to block unsupported provider or scope combinations explicitly and MUST emit the same shared summary contract used by resource, onboarding, audit, and run surfaces. +- **FR-008**: Provider-connection audit metadata MUST use the neutral target-scope descriptor fields in shared audit context and MUST keep Microsoft-specific identity context nested under provider-owned detail only. +- **FR-009**: `ProviderOperationStartGate` MUST write provider-neutral `target_scope` fields into `OperationRun` context and MUST NOT use `target_scope.entra_tenant_id` as the shared contract key. +- **FR-010**: Any Microsoft-specific scope or profile data still needed by provider-operation follow-up, consent, or support flows MUST live inside a clearly provider-owned nested context or metadata block rather than in the shared `target_scope` shape. +- **FR-011**: `ProviderConnectionResource` list, create, view, and edit surfaces MUST show neutral target-scope labels and summaries in their shared columns, sections, and helper text while moving Microsoft-specific profile detail into provider-owned disclosure. +- **FR-012**: `ProviderConnectionResource` MUST remain non-globally-searchable in this slice. Its existing View and Edit pages remain the canonical inspect and mutation surfaces. +- **FR-013**: `TenantResource` managed-environment detail summaries and related-context entries that advertise provider connections MUST use the same shared target-scope summary contract as `ProviderConnectionResource`. +- **FR-014**: `ManagedTenantOnboardingWizard` MUST use the same shared target-scope summary contract for provider-connection options, readiness state, and supporting audit metadata as the provider-connections resource. +- **FR-015**: Provider-specific consent, required-permissions, portal, and troubleshooting detail MUST remain provider-owned guidance and MUST NOT redefine shared platform nouns such as `workspace`, `managed environment`, `provider connection`, or `target scope`. +- **FR-016**: The implementation MUST NOT introduce a dedicated provider-profile table, a provider-profile registry, a capability registry, a provider-neutral artifact taxonomy, or any other framework work reserved for Specs `282`-`287`. +- **FR-017**: The feature MUST preserve the existing provider-connection action family and provider-operation start entry points, changing only the shared connection or profile semantics they expose. +- **FR-018**: `config/provider_boundaries.php` and any related review proof touched by implementation MUST classify the remaining Microsoft-specific exceptions explicitly instead of leaving them implicit in platform-core seams. + +### Authorization and Safety Requirements + +- **AR-001**: Workspace membership MUST remain the first access boundary for provider-connections and onboarding provider-connection flows. +- **AR-002**: Managed-environment entitlement MUST remain the second access boundary for provider-connections list, detail, create, or edit access and onboarding connection selection. +- **AR-003**: Non-members or cross-workspace or cross-environment access attempts MUST resolve as `404`, while in-scope actors missing provider capabilities MUST resolve as `403`. +- **AR-004**: Mutating provider-connection actions such as setting default, enabling dedicated override, rotating or deleting credentials, reverting to platform, and enabling or disabling the connection MUST remain server-authorized and use confirmation where the existing action contract requires it. +- **AR-005**: Navigation-only consent actions such as `Grant admin consent` MUST remain capability-gated and truthful about being navigation, not mutation. +- **AR-006**: Provider-operation starts triggered from the touched surfaces MUST continue to use the shared provider-operation gate and existing capability checks before any run is created. + +### Non-Functional Requirements + +- **NFR-001**: Filament remains v5 on Livewire v4. +- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; this slice does not move any provider or panel registration into `bootstrap/app.php`. +- **NFR-003**: Asset strategy remains unchanged. No new panel or shared asset registration is expected; if a later implementation registers assets unexpectedly, deployment continues to use `cd apps/platform && php artisan filament:assets`. +- **NFR-004**: `ProviderConnectionResource` remains non-globally-searchable, and any touched searchable resource such as `TenantResource` must keep its valid view destination intact. +- **NFR-005**: The feature must stay reviewable as one bounded slice and must not silently absorb route-cutover work from Spec `280` or capability, taxonomy, RBAC, copy, or quality-gate work from Specs `282`-`287`. +- **NFR-006**: The touched Filament surfaces must continue to follow the current TenantPilot enterprise UI standard, existing action hierarchy, and native Filament semantics rather than introducing local card, button, or status styling. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List or Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create or Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections list | `ProviderConnectionResource` -> `ListProviderConnections` | `New connection` | `recordUrl()` clickable row to View | `More` group containing `Edit`, `Check connection`, `Inventory sync`, `Compliance snapshot`, `Set as default`, `Enable dedicated override`, `Rotate dedicated credential`, `Delete dedicated credential`, `Revert to platform`, `Enable connection`, `Disable connection` | none | `New connection` | `N/A` | `N/A` | existing mutation actions already write audit logs where required | Resource remains non-globally-searchable and keeps one inspect model via clickable row | +| Provider connection view and edit surfaces | `ProviderConnectionResource` -> `ViewProviderConnection`, `EditProviderConnection`, `CreateProviderConnection` | `Grant admin consent` plus existing `More` action group on View; existing Edit or Create header behavior stays native | `N/A` | none beyond the shared `More` group on View or Edit | none | `N/A` | `Grant admin consent`, `More` | native save or cancel flow | existing mutation actions already write audit logs where required | `Grant admin consent` is navigation-only; mutating actions in `More` remain server-authorized and confirmation-protected where dangerous | +| Managed-environment onboarding provider-connection step | `ManagedTenantOnboardingWizard` provider-connection selection or readiness step | none | explicit in-step selector and create or manage actions only | none | none | existing onboarding create or select affordances only | `N/A` | wizard save or continue semantics only | existing onboarding connection changes already write audit logs | This slice changes summary semantics and supporting links only; it does not introduce a second onboarding action family | + +### Key Entities *(include if feature involves data)* + +- **Provider Connection**: the existing runtime binding between one managed environment and one provider, including connection type, default state, consent state, verification state, and attached credentials. +- **Target Scope Descriptor**: the shared neutral summary of what platform scope a provider connection represents, including provider, scope kind, scope identifier, and scope display name. +- **Provider Identity Resolution**: the shared runtime result that determines effective client identity, credential source, target scope, and blocked or resolved state for one provider connection. +- **Provider Profile Detail**: provider-owned Microsoft-specific metadata and guidance such as tenant directory identity, consent or required-permissions links, domains, portal links, and similar follow-up detail that must remain secondary to the shared target-scope summary. +- **Provider Operation Context**: the `OperationRun` identity and context payload created from a provider-backed start path, linking one run to one provider connection and one normalized target scope. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of affected default-visible provider-connection summaries use neutral target-scope wording instead of requiring a Microsoft-specific field label as the primary identity signal. +- **SC-002**: An operator can move from managed-environment detail or onboarding to the correct provider-connection detail surface and identify the connection's target scope in 3 interactions or fewer. +- **SC-003**: 100% of provider operations started from the affected surfaces carry enough shared provider-connection and target-scope information that follow-up run and audit surfaces can identify the target without reconstructing it from Microsoft-only context. +- **SC-004**: Microsoft-specific consent and profile details remain accessible for troubleshooting and admin-consent follow-up without becoming the primary visible identity vocabulary on affected shared platform surfaces. diff --git a/specs/281-provider-connection-scope/tasks.md b/specs/281-provider-connection-scope/tasks.md new file mode 100644 index 00000000..63c46360 --- /dev/null +++ b/specs/281-provider-connection-scope/tasks.md @@ -0,0 +1,214 @@ +--- +description: "Task list for Provider Connection Scope & Microsoft Profile Extraction" +--- + +# Tasks: Provider Connection Scope & Microsoft Profile Extraction + +**Input**: Design documents from `specs/281-provider-connection-scope/` +**Prerequisites**: `specs/281-provider-connection-scope/spec.md`, `specs/281-provider-connection-scope/plan.md`, `specs/281-provider-connection-scope/checklists/requirements.md`, `specs/281-provider-connection-scope/research.md`, `specs/281-provider-connection-scope/data-model.md`, `specs/281-provider-connection-scope/quickstart.md`, and `specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml` +**Implementation Posture**: Runtime work, targeted test execution, browser smoke, and dirty-file formatting validation have been completed for this package. +**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php`, `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php`, `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php`, `apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php`, and `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php`. +**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, the existing `ProviderOperationStartResultPresenter` path, `OperationUxPresenter`, and the current `OperationRunService` lifecycle ownership. This slice only changes the shared target-scope and identity context carried through that existing provider-operation path. +**RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, and `PROVIDER_RUN` denials remain `403`. +**Shared Pattern Reuse**: Reuse `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolver`, `ProviderIdentityResolution`, `ProviderOperationStartGate`, `ProviderConnectionResource`, and `ManagedTenantOnboardingWizard`. Do not introduce app-wide provider frameworks, provider-profile tables, capability registries, artifact taxonomy work, RBAC redesign, routing cutover work, or copy-neutralization work. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `ProviderConnectionResource` remains non-globally-searchable while keeping `View` and `Edit` pages. Any touched destructive action must continue to use `->action(...)`, `->requiresConfirmation()`, and current server authorization. Asset strategy stays unchanged. +**Compatibility Posture**: Reject shared `tenantContext` as canonical platform-core naming, reject shared `target_scope.entra_tenant_id`, reject dual-write compatibility aliases for Microsoft-shaped shared truth, and keep Specs `282` through `287` deferred. +**Organization**: Tasks are grouped by user story so provider-connection summary truth, provider-operation context, onboarding convergence, and managed-environment related-context drill-in stay independently testable. +**Review Outcome**: `implementation-ready` +**Workflow Outcome**: `keep` +**Test-governance Outcome**: `keep` + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane. +- [x] New or changed tests stay in the named feature and browser files only. +- [x] Workspace, managed-environment, provider-connection, optional credential, and run fixtures remain explicit and opt-in; no new defaults, registry setup, or provider-profile persistence is planned. +- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly. +- [x] `standard-native-filament`, `workflow-hub`, and `global-context-shell` expectations stay explicit for touched surfaces. +- [x] Any attempt to absorb Specs `282` through `287` resolves as `split` or `reject-or-split`, not hidden follow-up inside `281`. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded provider-boundary inventory, the proof files, and the explicit deferred-scope posture before implementation begins. + +- [x] T001 Review `specs/281-provider-connection-scope/spec.md`, `specs/281-provider-connection-scope/plan.md`, `specs/281-provider-connection-scope/checklists/requirements.md`, `specs/281-provider-connection-scope/research.md`, `specs/281-provider-connection-scope/data-model.md`, `specs/281-provider-connection-scope/quickstart.md`, and `specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml` together so implementation stays on Spec `281` only. +- [x] T002 [P] Confirm the current persisted-truth and Filament guardrail seams in `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` before changing shared summaries. +- [x] T003 [P] Confirm the current shared target-scope and surface-summary seams in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`. +- [x] T004 [P] Confirm the current provider-resolution and identity-result seams in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/CredentialManager.php`. +- [x] T005 [P] Confirm the current provider-operation and provider-owned consumer seams in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, and `apps/platform/app/Services/Providers/ProviderGateway.php`. +- [x] T006 [P] Confirm the current provider-boundary classification and deferred-scope limits in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, `apps/platform/config/provider_boundaries.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/281-provider-connection-scope/checklists/requirements.md` so Specs `282` through `287` remain explicitly out of scope. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the proving suite and the canonical shared target-scope or provider-boundary ownership that every story depends on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php` for the neutral target-scope contract across `ProviderConnectionResolver`, `ProviderConnectionSurfaceSummary`, and `ProviderOperationStartGate` so the same provider connection does not produce parallel Microsoft-shaped shared truth. +- [x] T008 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php` for the `ProviderIdentityResolution` contract shift away from shared `tenantContext` naming and toward `target_scope`, effective client identity, credential source, blocked reason, and nested provider context. +- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php` for started and blocked provider operations to use neutral shared `target_scope` context plus nested provider context instead of shared `target_scope.entra_tenant_id`. +- [x] T010 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php` for Microsoft-shaped shared-contract regressions, including shared `tenantContext`, shared `target_scope.entra_tenant_id`, or new compatibility aliases reappearing in platform-core seams. +- [x] T011 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php` for the managed-environment related-context entry, the provider-connections detail summary, and onboarding summary continuity under the live Filament shell. +- [x] T012 Reconcile shared seam ownership in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, `apps/platform/config/provider_boundaries.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` so later story work consumes one canonical neutral `target_scope` contract and keeps Microsoft profile detail nested. + +**Checkpoint**: The proving suite exists, the platform-core versus provider-owned boundary is explicit, and later stories can reuse one canonical neutral target-scope contract without reopening Specs `282` through `287`. + +--- + +## Phase 3: User Story 1 - Inspect a provider connection with one neutral target-scope summary (Priority: P1) + +**Goal**: Provider-connections list and detail surfaces tell the operator immediately which managed environment and target scope a connection represents without forcing Microsoft-specific field names into the shared summary. + +**Independent Test**: Open the provider-connections list and one connection detail page for a managed environment, then confirm the default-visible summary shows provider, target scope, and health first while Microsoft profile detail stays nested under provider-owned disclosure. + +### Tests for User Story 1 + +- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php` after T012 to prove `ProviderConnectionResolver` and `ProviderConnectionSurfaceSummary` return the same neutral `target_scope` contract for valid, blocked, and review-needed connections without depending on shared `entra_tenant_id` naming. +- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php` after T012 to prove `ProviderConnectionResource` list, view, and edit context surfaces show provider, target scope, lifecycle, consent, and verification first, keep provider-owned Microsoft profile detail secondary, and remain non-globally-searchable with `View` and `Edit` destinations intact. + +### Implementation for User Story 1 + +- [x] T015 [US1] Update `apps/platform/app/Services/Providers/ProviderConnectionResolver.php` and `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` so the platform-core connection-resolution contract publishes the canonical neutral `target_scope` descriptor instead of shared Microsoft-shaped naming. +- [x] T016 [US1] Update `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` and `apps/platform/app/Models/ProviderConnection.php` only as needed so target-scope summary text derives from the canonical descriptor and still handles invalid or review-needed scope states explicitly. +- [x] T017 [US1] Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` so provider-connections surfaces converge on the shared summary adapter, keep provider profile or context detail nested, and preserve current confirmation-protected destructive actions. + +**Checkpoint**: Provider-connections list and detail surfaces now tell one neutral target-scope story before any provider-specific profile detail is disclosed. + +--- + +## Phase 4: User Story 2 - Start provider work without Microsoft-shaped shared run context (Priority: P1) + +**Goal**: Provider-backed operations started from provider-connections or onboarding surfaces record neutral shared target-scope context so later run follow-up, audit, and support flows do not depend on Microsoft-only keys. + +**Independent Test**: Start one provider-backed operation from a provider connection and verify that the resulting run can be identified by provider connection and target scope without relying on shared `target_scope.entra_tenant_id`. + +### Tests for User Story 2 + +- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php` after T012 to prove shared identity results no longer expose `tenantContext` as platform-core truth and instead expose `target_scope`, effective client identity, credential source, blocked reason, and nested provider context across platform and dedicated credential paths. +- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php` after T012 to prove started and blocked results record neutral `target_scope` plus nested provider context details and never require shared `target_scope.entra_tenant_id`. + +### Implementation for User Story 2 + +- [x] T020 [US2] Update `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php` so the canonical shared identity contract shifts away from `tenantContext` naming while preserving provider-owned Microsoft detail as nested context only. +- [x] T021 [US2] Update `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` so shared `OperationRun` context, blocked or start feedback, and shared audit metadata carry the canonical neutral `target_scope` descriptor plus nested provider context. +- [x] T022 [US2] Update `apps/platform/app/Services/Providers/CredentialManager.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, and `apps/platform/app/Services/Providers/ProviderGateway.php` only where required to consume the shifted identity contract without reintroducing shared `tenantContext` or shared `target_scope.entra_tenant_id` aliases. + +**Checkpoint**: Provider-operation start and blocked flows now carry one neutral shared run-context contract, with Microsoft detail available only through nested provider-owned context. + +--- + +## Phase 5: User Story 3 - See the same connection story in onboarding and provider settings (Priority: P2) + +**Goal**: The onboarding wizard describes provider connections with the same target-scope summary used on the provider-connections resource so operators do not need to reinterpret the same connection in a second vocabulary. + +**Independent Test**: Select or create a provider connection from onboarding and confirm that the displayed target-scope summary matches the one shown on the provider-connections resource for the same record. + +### Tests for User Story 3 + +- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php` after T012 to prove onboarding option lists and selected-connection readiness reuse the same `ProviderConnectionSurfaceSummary` target-scope contract as the provider-connections resource for the same record, and that provider-owned required-permissions guidance stays nested under `permission_overview.required_permissions_url` rather than becoming a second shared summary path. + +### Implementation for User Story 3 + +- [x] T024 [US3] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and confirm `apps/platform/app/Support/Links/RequiredPermissionsLinks.php` required no code change so connection selection, readiness, verification, and required-permissions guidance consume the shared `ProviderConnectionSurfaceSummary` contract while keeping provider-owned guidance nested under `permission_overview` instead of local identity wording. +- [x] T025 [US3] Update `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` together where needed so `ProviderConnectionResource` and onboarding converge on one summary presenter rather than parallel formatting branches. + +**Checkpoint**: Provider-connections and onboarding now describe the same connection with the same summary contract and supporting detail hierarchy. + +--- + +## Phase 6: User Story 4 - Jump from managed-environment detail into provider connections without duplicate truth (Priority: P3) + +**Goal**: The managed-environment detail page advertises provider-connection state and takes operators into the provider-connections resource without duplicating the full provider profile summary on the overview page. + +**Independent Test**: Open one managed-environment detail page, use its provider-connections related-context entry, and verify that the overview stays summary-first while the provider-connections resource becomes the primary decision surface. + +### Tests for User Story 4 + +- [x] T026 [P] [US4] Extend `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php` after T012 to prove the managed-environment detail entry opens provider connections scoped to the current environment and the live shell still shows the same shared target-scope summary without duplicating full provider profile detail on the overview page. + +### Implementation for User Story 4 + +- [x] T027 [US4] Update `apps/platform/app/Filament/Resources/TenantResource.php` so managed-environment related-context provider summaries stay minimal, reuse the shared target-scope summary contract, and deep-link into `ProviderConnectionResource` with the current `managed_environment_id` context. +- [x] T028 [US4] Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` only as needed so environment-scoped deep links preserve the same shared target-scope summary and keep provider-profile disclosure on the primary decision surface instead of the overview page. + +**Checkpoint**: Managed-environment detail stays calm and summary-first, while provider-connections remains the primary decision surface for connection identity and troubleshooting. + +--- + +## Phase 7: Polish & Cross-Cutting Validation + +**Purpose**: Run the exact bounded proof set, perform the final Filament and provider-boundary review, and confirm the package stayed inside Spec `281`. + +- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)`. +- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)`. +- [x] T031 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`. +- [x] T032 [P] Review `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/config/provider_boundaries.php`, and `apps/platform/bootstrap/providers.php` to confirm Filament v5 or Livewire v4 compliance, unchanged provider registration location, unchanged asset strategy, `ProviderConnectionResource` non-global-search posture, preserved destructive-action confirmation plus authorization, provider-owned required-permissions guidance staying nested under `permission_overview`, and that Specs `282` through `287` remained deferred without new provider-profile tables, registries, taxonomy work, RBAC redesign, routing cutover, or copy-neutralization work. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical neutral provider-connection summary contract. +- **Phase 4 (US2)**: depends on Phase 2 and should land after or with US1 so provider-operation context carries the same canonical target-scope contract shown on provider-connections surfaces. +- **Phase 5 (US3)**: depends on US1 and should land after or with US2 when onboarding readiness also consumes the shifted identity contract. +- **Phase 6 (US4)**: depends on US1 and US3 so managed-environment related context advertises the final shared summary instead of an intermediate contract. +- **Phase 7 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and is the first required implementation increment. +- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because shared run context should match the final provider-connection summary contract. +- **US3 (P2)**: independently testable after US1 and should follow once the shared summary presenter is stable. +- **US4 (P3)**: independently testable after US1 and US3 and closes the summary-first versus drill-in boundary on managed-environment detail. + +### Within Each User Story + +- Write or extend the listed Pest coverage first and make it fail for the intended gap. +- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `282` through `287`. +- Re-run the narrowest relevant validation command for that story before moving to the next story. + +## Parallel Execution Examples + +- **Setup**: T002 through T006 can run in parallel once T001 sets the bounded scope. +- **Foundational**: T007 through T011 can run in parallel before T012 converges the canonical boundary and target-scope ownership. +- **US1**: T013 and T014 can run in parallel; T015 through T017 should merge serially around resolver, summary, and Filament resource files. +- **US2**: T018 and T019 can run in parallel; T020 through T022 should follow serially around the shared identity and start-gate seams. +- **US3**: T023 can run alongside T024, then T025 should converge the shared presenter. +- **US4**: T026 can run alongside T027, then T028 should finalize the deep-link and summary-first resource behavior. +- **Polish**: T029 through T031 can run in parallel after implementation is complete; T032 should close the bounded-scope review last. + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1**. Land the neutral provider-connection summary contract first so every later consumer reads one canonical target-scope story. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 so provider-connections stops depending on Microsoft-shaped shared summary truth. +3. Deliver US2 so provider-operation start and blocked flows record the same neutral shared target-scope contract. +4. Deliver US3 so onboarding reuses the same summary presenter and identity language. +5. Deliver US4 so managed-environment detail stays summary-first and drills into the provider-connections decision surface cleanly. +6. Finish with the exact validation commands and the final bounded-scope review in Phase 7. + +### Team Strategy + +1. Parallelize the failing test work first. +2. Serialize merges around `apps/platform/app/Support/Providers/TargetScope/`, `apps/platform/app/Services/Providers/`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` to avoid conflicting contract-shape edits. +3. Reject any implementation branch that introduces provider-profile persistence, shared compatibility aliases, registry or taxonomy work, routing cutover work, RBAC redesign, or copy-neutralization tasks reserved for Specs `282` through `287`. + +## Deferred Follow-Ups / Non-Goals + +- Spec `282` governance-artifact retargeting to `ManagedEnvironment` +- Spec `283` provider capability registry work +- Spec `284` provider-neutral artifact source or taxonomy work +- Spec `285` workspace-first RBAC and environment-access redesign +- Spec `286` broader UI copy, IA, and localization neutralization +- Spec `287` cutover quality gates and no-legacy enforcement beyond this feature-local proof