TenantAtlas/specs/078-operations-tenantless-canonical/spec.md
ahmido d56ba85755 Spec 078: Operations tenantless canonical detail (#95)
Implements Spec 078 operations tenantless canonical migration.

Highlights:
- Canonical run detail at `/admin/operations/{run}` renders with standard Filament chrome + sidebar and reuses `OperationRunResource::infolist()` (schema-based, Filament v5).
- Legacy tenant-scoped resource pages removed; legacy URLs return 404 as required.
- Added full spec test pack under `tests/Feature/078/` and updated existing tests.
- Added safe refresh/header actions wiring and KPI header guard when tenant context is null.

Validation:
- `vendor/bin/sail artisan test --compact tests/Feature/078/` (pass)
- `vendor/bin/sail bin pint --dirty` (pass)

Notes:
- Livewire v4+ compliant (Filament v5).
- Panel providers remain registered in `bootstrap/providers.php` (Laravel 11+ standard).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #95
2026-02-07 09:07:26 +00:00

344 lines
20 KiB
Markdown

# Feature Specification: Operations Tenantless Canonical Migration
**Feature Branch**: `078-operations-tenantless-canonical`
**Created**: 2026-02-06
**Status**: Draft
**Stack**: Filament v5 + Livewire v4 (native only)
**Input**: Eliminate dual "run detail" surfaces (tenant-scoped Filament view vs tenantless canonical viewer) and make Operations truly canonical, supportable, and secure.
---
## 0. Executive Summary
Operations is already **canonical at the index and tenantless detail** (`/admin/operations`, `/admin/operations/{run}`), but **auto-generated tenant-scoped Filament resource routes** still exist as side-effects of panel discovery:
- Tenant-scoped detail: `/admin/t/{tenant}/operations/r/{record}`
- Tenant-scoped list: `/admin/t/{tenant}/operations`
This spec makes the tenantless detail page **the only run detail view**, removes the auto-generated tenant-scoped pages (legacy detail URLs naturally 404), and ensures **deny-as-not-found** security—without introducing non-native UI frameworks.
---
## Clarifications
### Session 2026-02-06
- Q: Should a tenant-scoped legacy URL (`/admin/t/{tenant}/operations/r/{record}`) for a run with `tenant_id = null` be allowed (redirect) or blocked (404)? → A: ~~Allow redirect~~ Superseded — FR-078-005/006 removed; legacy detail URLs naturally 404 after route decommission.
---
## 1. Goals
1. **Single canonical run detail view**`/admin/operations/{run}`
2. **Remove auto-generated tenant-scoped pages** — decommission both `/admin/t/{tenant}/operations/r/{record}` and `/admin/t/{tenant}/operations`
3. **Clean decommission** — legacy tenant-scoped detail URLs naturally 404 after route removal (no redirect handlers needed)
4. **No duplication of UI logic** — reuse `OperationRunResource::infolist()` in the tenantless viewer
5. **Enterprise security semantics** — non-member → 404, no existence leakage
---
## 2. Non-Goals
- Building a new operations dashboard
- Introducing an alerts engine
- Changing operation execution semantics
- Implementing fine-grained `operations.view` capabilities (access remains workspace-membership)
- Workspace-scoped KPI header (tracked separately; header hidden in tenantless mode for Phase 1)
---
## 3. Current State (Baseline)
**Registered routes (today):**
| Route | Pattern | Name | Handler | Scope |
|-------|---------|------|---------|-------|
| Canonical list | `GET /admin/operations` | `admin.operations.index` | `Operations.php` (custom page) | Workspace (session) |
| Canonical detail | `GET /admin/operations/{run}` | `admin.operations.view` | `TenantlessOperationRunViewer.php` | Workspace membership |
| Auto-generated list | `GET /admin/t/{tenant}/operations` | `filament.admin.resources.operations.index` | `ListOperationRuns.php` | Tenant-scoped |
| Auto-generated view | `GET /admin/t/{tenant}/operations/r/{record}` | `filament.admin.resources.operations.view` | `ViewOperationRun.php` | Tenant-scoped |
**Key constraints:**
- Tenant-scoped routes exist because `OperationRunResource` is auto-discovered in a tenant panel (`AdminPanelProvider::discoverResources`).
- All production "View run" links already point to canonical tenantless detail via `OperationRunLinks::view()``OperationRunLinks::tenantlessView()`.
- The resource already declares `$isScopedToTenant = false` and `$shouldRegisterNavigation = false`.
**Link helper state:**
- `OperationRunLinks::view($run, $tenant)` delegates to `tenantlessView($run)` — the `$tenant` parameter is a no-op.
- `OperationRunLinks::related($run, $tenant)` returns up to 11 contextual links (Policies, Inventory, Drift, Backup Sets, Provider Connections, etc.).
- `OperationRunUrl::view()` and `OperationRunUrl::index()` are thin wrappers around `OperationRunLinks`.
---
## 4. Enterprise Principles (Normative)
### P-078-001 — Canonical deep links
There MUST be exactly one canonical URL to open a run: `/admin/operations/{run}`.
### P-078-002 — No tenant context dependency
Canonical run detail MUST render without tenant context and MUST NOT require `/admin/t/{tenant}`.
### P-078-003 — Deny-as-not-found
Any user not entitled to view a run MUST receive **404**, not 403. (Constitution: RBAC-UX-002)
### P-078-004 — No leakage via legacy URLs
Decommissioned tenant-scoped routes MUST NOT exist after migration. Removed routes naturally return 404 — no redirect handlers, no existence leakage.
### P-078-005 — Filament-native
Use Filament pages/resources/infolists/actions and Livewire v4 only. No custom SPA routing.
---
## User Scenarios & Testing
### User Story 1 — View operation run via canonical URL (Priority: P1)
A workspace member clicks a "View run" link (from notification, widget, or operations list) and sees the full run detail at `/admin/operations/{run}` — regardless of whether the run has a tenant or not.
**Why this priority**: This is the core feature — the single canonical detail surface that replaces the dual-surface.
**Independent Test**: Create runs with and without `tenant_id`, navigate to `/admin/operations/{run}`, assert all sections render (summary, target scope, verification report, context JSON).
**Acceptance Scenarios**:
1. **Given** a run with `tenant_id` set and user is workspace member, **When** user visits `/admin/operations/{run}`, **Then** full detail renders including target scope, verification report (if present), summary counts, and context JSON.
2. **Given** a run with `tenant_id = null` (e.g., onboarding run), **When** user visits `/admin/operations/{run}`, **Then** detail renders without crash; target scope shows "No target scope details recorded."
3. **Given** a run with a verification report in context, **When** user visits canonical detail, **Then** verification report section renders correctly with badge rendering and acknowledgements — even though `Filament::getTenant()` returns null.
4. **Given** user is NOT a workspace member, **When** user visits `/admin/operations/{run}`, **Then** 404 (deny-as-not-found).
---
### User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
A user has a bookmarked or old notification link pointing to `/admin/t/{tenant}/operations/r/{record}`. After route decommission, the system returns 404 — no redirect, no existence leakage.
**Why this priority**: Ensures decommissioned routes don't silently serve stale pages or leak information.
**Independent Test**: Hit legacy detail URLs; assert 404 for all users regardless of membership.
**Acceptance Scenarios**:
1. **Given** any user (member or non-member), **When** user visits `/admin/t/{tenant}/operations/r/{record}`, **Then** 404.
2. **Given** any user, **When** user visits `/admin/operations/r/{record}`, **Then** 404 (the `/r/` slug variant also does not exist).
---
### User Story 3 — Contextual navigation from run detail (Priority: P2)
On the canonical detail page, a workspace member sees contextual "Open" actions (e.g., "Open tenant", "Policies", "Backup Set", "Provider Connection") based on the run's context — using the existing `OperationRunLinks::related()` mechanism.
**Why this priority**: Replaces the "Admin details" back-link with richer, already-implemented navigation.
**Independent Test**: Create runs of different types, verify that the related links appear in the header actions group.
**Acceptance Scenarios**:
1. **Given** a run of type `policy.sync` with `policy_id` in context, **When** viewing canonical detail, **Then** header shows "Open" group with links to Operations index, Policy, and Policies list.
2. **Given** a run with `tenant_id` and user is tenant member, **When** viewing canonical detail, **Then** related links include tenant-scoped resources (via `OperationRunLinks::related()`).
3. **Given** a run with `tenant_id = null`, **When** viewing canonical detail, **Then** no tenant-specific related links appear; only generic links (Operations index).
---
### User Story 4 — Operations list remains workspace-scoped (Priority: P3)
The `/admin/operations` page continues to show all runs for the current workspace, with an optional tenant default filter when tenant context is active.
**Why this priority**: Existing behavior; this story ensures no regression during migration.
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping and default filter behavior.
**Acceptance Scenarios**:
1. **Given** workspace context set but no tenant context, **When** visiting `/admin/operations`, **Then** all workspace runs shown.
2. **Given** tenant context active, **When** visiting `/admin/operations`, **Then** tenant filter defaults to active tenant but can be cleared to show all.
---
### Edge Cases
- Run with `workspace_id = 0` or null → 404 (existing behavior in `OperationRunPolicy::view`)
- Run exists but requesting user has no workspace membership → 404 (deny-as-not-found)
- Verification report section with `Filament::getTenant()` returning null → falls back to `OperationRunLinks::tenantlessView()` for previous-run URLs (already handled in infolist)
- Legacy tenant-scoped URLs (`/admin/t/{tenant}/operations/r/{record}`) → 404 (routes no longer registered after decommission)
---
## Requirements
**Constitution alignment (RBAC-UX):**
- Authorization plane: Workspace-level (not tenant-level). Operations detail requires workspace membership only.
- 404 vs 403: Non-member → 404 (RBAC-UX-002). No capability-gating for view-only operations access in this spec.
- Server-side enforcement: `OperationRunPolicy::view()` + `WorkspaceMembership` check in `TenantlessOperationRunViewer::mount()`.
- Legacy tenant-scoped routes are decommissioned (naturally 404); no redirect handlers needed (P-078-004).
- Global search: `OperationRunResource` has `$shouldRegisterNavigation = false` and no `$recordTitleAttribute` — not globally searchable.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no auth handshakes involved.
**Constitution alignment (BADGE-001):** No badge changes. Existing badge mappings (`OperationRunStatus`, `OperationRunOutcome`) are reused via shared `BadgeRenderer` in the reused infolist.
### Functional Requirements
#### 5.1 Canonical Run Detail — Feature-Complete Tenantless Page
- **FR-078-001**: `GET /admin/operations/{run}` MUST display: run summary (status/outcome/timestamps/initiator), target scope, verification report (DB-only), summary counts, failure summary, and context JSON (redacted).
- **FR-078-002**: Tenantless detail page MUST reuse `OperationRunResource::infolist()` via Filament v5's native schema system. `TenantlessOperationRunViewer` MUST define `public function infolist(Schema $schema): Schema` delegating to `OperationRunResource::infolist($schema)`, provide `defaultInfolist(Schema $schema)` to bind `->record($this->run)->columns(2)`, and render the schema via `{{ $this->infolist }}` (or `EmbeddedSchema::make('infolist')` in `content()`).
- **FR-078-003**: Tenantless detail MUST reuse `OperationRunLinks::related($run, $tenant)` for contextual header actions (replacing the removed "Admin details" button). If `$run->tenant` is null, only generic links (Operations index) appear.
#### 5.2 Decommission Auto-Generated Tenant-Scoped Pages
- **FR-078-004**: Remove the `'view'` page entry from `OperationRunResource::getPages()` and delete `ViewOperationRun.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations/r/{record}` route.
- **FR-078-011**: Remove the `'index'` page entry from `OperationRunResource::getPages()` and delete `ListOperationRuns.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations` route. The resource retains only its `table()` and `infolist()` schema builders for reuse by the custom pages.
#### 5.3 Legacy URL Handling
Legacy detail URLs (`/admin/t/{tenant}/operations/r/{record}` and `/admin/operations/r/{record}`) naturally return 404 after route decommission — no redirect handlers are needed. ~~FR-078-005 and FR-078-006 removed.~~
- **FR-078-012**: Add a convenience redirect for `GET /admin/t/{tenant}/operations`:
1. Redirect **302** to `/admin/operations`.
Note: No auth check needed since `/admin/operations` already enforces workspace membership.
#### 5.4 KPI Header Handling (Phase 1 Simplification)
- **FR-078-008**: `OperationsKpiHeader` widget MUST continue to render when tenant context is available. When no tenant context exists (tenantless pages), the KPI header SHOULD be hidden (not rendered) rather than showing zeros. Full workspace-scoped KPI support is deferred to a follow-up spec.
- **FR-078-009**: Polling/active-run queries on the canonical list page (`Operations.php`) MUST handle `tenant_id = null` runs and tenantless rendering safely. This MUST be verified by an explicit regression test covering list rendering with and without tenant context.
#### 5.5 Dead Code Cleanup
- **FR-078-010**: Delete `app/Livewire/Monitoring/OperationsDetail.php` and its Blade view `resources/views/livewire/monitoring/operations-detail.blade.php`. This component is unreferenced dead code that enforces an obsolete tenant-only abort check.
### Key Entities
- **OperationRun**: The central model. Has `workspace_id` (required for authorization), `tenant_id` (nullable — onboarding/provider-check runs may lack it), `context` (JSONB with target_scope, verification_report, etc.), `summary_counts`, `failure_summary`.
- **WorkspaceMembership**: The authorization boundary. View access requires membership in the run's workspace.
- **OperationRunLinks**: Centralized link helper. `view()``tenantlessView()` (already canonical). `related()` provides contextual navigation links.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Exactly one canonical run detail URL exists: `/admin/operations/{run}`. The route `filament.admin.resources.operations.view` is no longer registered.
- **SC-002**: All "View run" links across notifications, widgets, tables, and jobs resolve to `/admin/operations/{run}` (no tenant-scoped detail links remain).
- **SC-003**: Legacy tenant-scoped detail URLs return 404 after route decommission (no redirect handlers, no information leakage).
- **SC-004**: Runs with `tenant_id = null` render on canonical detail without errors.
- **SC-005**: Verification report section renders correctly on canonical detail when `Filament::getTenant()` returns null.
- **SC-006**: All existing Pest tests pass after migration (no regressions).
---
## Security & RBAC
### SR-078-001 — View permissions
- Viewing operations list and run detail requires workspace membership (baseline).
- Non-member → 404 (deny-as-not-found, per RBAC-UX-002).
- No fine-grained capabilities for operations view in this spec.
### SR-078-002 — No sensitive leakage in context JSON
- Context JSON shown in UI uses allowlist-based redaction (existing behavior).
- The verification report section already sanitizes sensitive data.
---
## UX Requirements
### UX-078-001 — Consistent CTA label
All run CTAs MUST use the canonical label per ux-contracts.md:
- **"View run"** (exact casing)
### UX-078-002 — Context navigation via related links
Canonical detail header MUST show an "Open" action group populated by `OperationRunLinks::related()`. This provides all relevant contextual links (Operations index, Provider Connection, Policies, Backup Sets, Restore Runs, Drift, Inventory, etc.) based on run type and context.
### UX-078-003 — Empty/missing target scope
If target scope is missing: show the existing non-blocking text "No target scope details were recorded for this run." Do not crash; degrade gracefully.
---
## Tests (Mandatory)
### T-078-001 — Canonical run detail renders without tenant context
- Create run with `tenant_id = null` and with `tenant_id` set
- Assert `/admin/operations/{run}` renders summary sections without crash
### T-078-002 — Legacy tenant-scoped detail URLs return 404
- Any user visiting `/admin/t/{tenant}/operations/r/{record}` → 404 (route not registered)
- Any user visiting `/admin/operations/r/{record}` → 404 (route not registered)
### T-078-004 — No auto-generated tenant-scoped routes exist
- Assert route names `filament.admin.resources.operations.view` and `filament.admin.resources.operations.index` are not registered (or return 404)
### T-078-005 — No "Admin details" link on canonical detail
- Assert canonical detail page does not render `/admin/t/.../operations/r/...` links
### T-078-006 — KPI header hidden in tenantless mode
- On `/admin/operations` without tenant context, KPI header does not render (or renders gracefully)
### T-078-007 — DB-only rendering
- Rendering canonical detail does not dispatch jobs or perform HTTP calls (existing guard tests remain valid)
### T-078-008 — Verification report renders on tenantless detail
- Create run with verification report in context and `tenant_id` set
- Visit `/admin/operations/{run}` without `Filament::getTenant()` set
- Assert verification report section renders (badge, acknowledgements, change indicator with tenantless previous-run URL)
### T-078-009 — Tenant-scoped list redirect
- `/admin/t/{tenant}/operations` redirects (302) to `/admin/operations`
### T-078-010 — Related links appear on canonical detail
- Create run of type `restore.execute` with `restore_run_id` in context
- Assert header actions include "Restore Run" link
### T-078-011 — Operations list tenantless safety
- Visit `/admin/operations` with and without tenant context
- Assert list renders successfully and includes `tenant_id = null` runs without polling/query errors
### T-078-012 — Canonical CTA label consistency
- Assert run entry points use the exact label **"View run"**
- Assert canonical detail no longer shows the legacy "Admin details" CTA
---
## Acceptance Criteria
- ✅ Exactly one canonical run detail URL: `/admin/operations/{run}`
- ✅ Auto-generated tenant-scoped routes (list + view) are removed; legacy detail URLs return 404
- ✅ No "Admin details" / tenant-scoped back-links from canonical pages
- ✅ Operations list works in workspace mode; tenant context only adds default filter
- ✅ Runs with `tenant_id = null` display safely
- ✅ Verification report renders correctly without tenant context
- ✅ Related contextual links (from `OperationRunLinks::related()`) replace the removed "Admin details" button
- ✅ Dead code (`OperationsDetail.php` + Blade view) removed
- ✅ Tests cover 404 semantics, infolist rendering, and canonical link rules
- ✅ Implementation is Filament v5 + Livewire v4 native only
---
## Implementation Notes (Non-Normative)
### Infolist Reuse Strategy
Use Filament v5's native schema flow on the standalone page:
- Define `infolist(Schema $schema)` and delegate to `OperationRunResource::infolist($schema)`
- Define `defaultInfolist(Schema $schema)` with `->record($this->run)->columns(2)`
- Render with `{{ $this->infolist }}` (and use `EmbeddedSchema::make('infolist')` from `content()` when needed)
### Resource After Decommission
After removing both page entries from `getPages()`, `OperationRunResource` becomes a "headless" resource — it provides `table()` and `infolist()` schema builders reused by:
- `Operations.php` (custom list page, via `OperationRunResource::table($table)`)
- `TenantlessOperationRunViewer` (canonical detail, via infolist delegation)
The resource class itself is retained; only its auto-generated routes are eliminated.
### KPI Header Deferral
`OperationsKpiHeader` currently queries 6 times by `tenant_id` and uses `ActiveRuns::existForTenant()`. Full workspace-scoping requires adding `existForWorkspace()` to `ActiveRuns` and refactoring all 6 queries. This is deferred to a separate spec to keep this migration focused. Phase 1 simply hides the KPI header when tenant context is absent.
---
## Open Decisions
- **"Copy JSON" capability-gating**: Recommended for enterprise environments but not in scope for this spec.