spec: 107 — Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point
This commit is contained in:
parent
8bee824966
commit
4b285104da
47
specs/107-workspace-chooser/checklists/requirements.md
Normal file
47
specs/107-workspace-chooser/checklists/requirements.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Specification Quality Checklist: Workspace Chooser v1 (Enterprise)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- Note: Spec references existing codebase components by name for context (WorkspaceContext, AuditActionId) but requirements are behavior-focused. Framework names appear in UI Action Matrix as required by template.
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders (user stories are plain language)
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified (zero memberships, archived workspace, stale session, forced chooser)
|
||||
- [x] Scope is clearly bounded (v1 vs v2 backlog explicit)
|
||||
- [x] Dependencies and assumptions identified (existing infrastructure documented in Context section)
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows (auto-resume single, auto-resume last-used, chooser fallback, revoked membership, manual switch, audit)
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification (behavior-first language throughout)
|
||||
|
||||
## Constitution Alignment
|
||||
|
||||
- [x] RBAC-UX: authorization plane stated (workspace /admin scope)
|
||||
- [x] RBAC-UX: 404 vs 403 semantics defined (non-member → 404)
|
||||
- [x] RBAC-UX: server-side enforcement described (middleware)
|
||||
- [x] RBAC-UX: capability registry referenced (Capabilities::WORKSPACE_MANAGE)
|
||||
- [x] Audit: audit events defined with payloads
|
||||
- [x] UX-001: exemption documented (context selector, not CRUD page)
|
||||
- [x] UI Action Matrix completed
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec deliberately uses `users.last_workspace_id` (existing column) instead of proposing a new `user_preferences` table, aligning with current codebase patterns.
|
||||
- Middleware algorithm is specified in detail because it's the core business logic of the feature; still expressed as behavior rules, not code.
|
||||
- All items pass. Spec is ready for `/speckit.plan`.
|
||||
300
specs/107-workspace-chooser/spec.md
Normal file
300
specs/107-workspace-chooser/spec.md
Normal file
@ -0,0 +1,300 @@
|
||||
# Feature Specification: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point
|
||||
|
||||
**Feature Branch**: `107-workspace-chooser`
|
||||
**Created**: 2026-02-22
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point: Auto-Resume, Switch vs Manage separation, Enterprise metadata, Audit events"
|
||||
|
||||
---
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: `/admin/choose-workspace`, `/admin/*` (middleware), user menu switch entry point
|
||||
- **Data Ownership**: workspace-owned (`workspaces`, `workspace_memberships`); user-owned (`users.last_workspace_id`)
|
||||
- **RBAC**: Any workspace member may switch/select; `workspace.manage` capability required for "Manage workspaces" link visibility in chooser
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
TenantPilot is workspace-first: a Workspace groups one or more Microsoft Tenants (customer environments). After login, an active workspace must be set so that RBAC, scoping, operations, findings, and all tenant-level features function correctly.
|
||||
|
||||
### Current State (What Exists)
|
||||
|
||||
The codebase already has foundational infrastructure:
|
||||
|
||||
- **`WorkspaceContext`** (`app/Support/Workspaces/WorkspaceContext.php`) — manages `session.current_workspace_id`, `users.last_workspace_id`, and provides `resolveInitialWorkspaceFor()` with partial auto-resume logic (session → last-used → single membership).
|
||||
- **`ChooseWorkspace`** page (`app/Filament/Pages/ChooseWorkspace.php`) — card grid with "Create workspace" header action, Livewire select, and POST-based form submit.
|
||||
- **`WorkspaceMembership`** pivot model with `role` column.
|
||||
- **Audit system** via `WorkspaceAuditLogger` + `AuditActionId` enum (workspace membership events already audited).
|
||||
- **`Capabilities::WORKSPACE_MANAGE`** already defined in the capability registry.
|
||||
|
||||
### What's Missing (Motivation)
|
||||
|
||||
1. **Switch vs. Manage conflation**: "Create workspace" is prominently placed in the chooser alongside selection.
|
||||
2. **No explicit auto-resume in middleware**: `resolveInitialWorkspaceFor()` exists but is not systematically called as middleware before every `/admin/*` request.
|
||||
3. **No race-condition handling with user feedback**: stale `last_workspace_id` or revoked membership causes silent fallback, no warning notification.
|
||||
4. **Minimal metadata**: chooser cards show name + "Last used" badge only — no role, no tenant count.
|
||||
5. **No audit events for workspace selection/switch** (only membership changes are audited).
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Auto-Resume: Single Workspace (Priority: P1)
|
||||
|
||||
A user with exactly one workspace membership logs in and is taken directly to their workspace dashboard without seeing the chooser screen.
|
||||
|
||||
**Why this priority**: Eliminates an unnecessary click for the majority of users (single-workspace scenario is the most common).
|
||||
|
||||
**Independent Test**: Create a user with one workspace membership, hit `/admin`, verify redirect to workspace dashboard without chooser.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with exactly 1 workspace membership and no session, **When** they visit `/admin`, **Then** they are redirected directly to the workspace dashboard and `session.current_workspace_id` is set.
|
||||
2. **Given** a user with exactly 1 workspace membership, **When** auto-resume fires, **Then** an audit event `workspace.auto_selected` with reason `single_membership` is emitted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1)
|
||||
|
||||
A user with multiple workspaces who has a valid `last_workspace_id` is taken directly to that workspace without the chooser.
|
||||
|
||||
**Why this priority**: Reduces friction for multi-workspace users (MSP/consulting scenario) on repeat visits.
|
||||
|
||||
**Independent Test**: Create a user with 2+ workspaces and a valid `last_workspace_id`, hit `/admin`, verify direct entry.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with 3 workspace memberships and `last_workspace_id` pointing to a valid membership, **When** they visit `/admin`, **Then** they land on that workspace's dashboard.
|
||||
2. **Given** the auto-resume via last-used fires, **Then** an audit event `workspace.auto_selected` with reason `last_used` is emitted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Chooser Fallback: Multiple Workspaces, No Default (Priority: P1)
|
||||
|
||||
A user with multiple workspaces and no valid `last_workspace_id` sees the chooser with enterprise metadata.
|
||||
|
||||
**Why this priority**: Core path — the chooser must show meaningful data to support quick selection.
|
||||
|
||||
**Independent Test**: Create a user with 3 workspaces (varying roles, tenant counts), clear `last_workspace_id`, visit `/admin`, verify chooser renders with metadata.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with 3 workspace memberships and no `last_workspace_id`, **When** they visit `/admin`, **Then** the chooser is displayed.
|
||||
2. **Given** the chooser renders, **Then** each workspace row shows: Name, Role badge, Tenants count, and an "Open" action.
|
||||
3. **Given** the chooser renders, **Then** "Create workspace" is not prominently shown. A "Manage workspaces" link appears only if the user has `workspace.manage` capability.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Stale Session / Revoked Membership (Priority: P2)
|
||||
|
||||
A user whose workspace membership was revoked between sessions sees a clear warning and is redirected to the chooser.
|
||||
|
||||
**Why this priority**: Race condition handling is essential for multi-tenant governance and prevents silent errors.
|
||||
|
||||
**Independent Test**: Set session to a workspace, delete the membership, visit `/admin`, verify warning + chooser.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with `session.current_workspace_id` pointing to a workspace where membership was revoked, **When** they visit `/admin`, **Then** the session is cleared and they are redirected to the chooser with a warning notification: "Your access to {workspace_name} was removed."
|
||||
2. **Given** a user with `last_workspace_id` pointing to a revoked membership and no session, **When** they visit `/admin`, **Then** `last_workspace_id` is cleared, and the chooser is shown with a warning.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Manual Workspace Switch (Priority: P2)
|
||||
|
||||
A user can switch workspaces from within the app via the user menu, which takes them to the chooser.
|
||||
|
||||
**Why this priority**: Users managing multiple tenants need an explicit switch path. This is the foundation for the in-app switcher.
|
||||
|
||||
**Independent Test**: As a logged-in user with active workspace, click "Switch workspace" in user menu, verify chooser loads with `?choose=1`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with an active workspace, **When** they click "Switch workspace" in the user menu, **Then** they are taken to `/admin/choose-workspace?choose=1`.
|
||||
2. **Given** a user selects a different workspace from the chooser, **Then** `session.current_workspace_id` is updated, `users.last_workspace_id` is updated, and an audit event `workspace.selected` with reason `chooser` is emitted.
|
||||
3. **Given** a user visits `/admin/choose-workspace?choose=1`, **Then** the chooser is shown regardless of auto-resume eligibility.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 — Audit Trail for Workspace Context Changes (Priority: P2)
|
||||
|
||||
Every workspace selection (auto or manual) produces an audit log entry for compliance.
|
||||
|
||||
**Why this priority**: MSP/compliance requirement — workspace context changes must be traceable.
|
||||
|
||||
**Independent Test**: Trigger auto-resume and manual selection, verify audit log entries with correct payloads.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** any workspace selection occurs, **Then** an audit log entry is created with: `actor_id`, `workspace_id`, `method` (auto|manual), `reason`, and optional `prev_workspace_id`.
|
||||
2. **Given** an auto-resume via single membership, **Then** the audit event reason is `single_membership`.
|
||||
3. **Given** a manual selection from the chooser, **Then** the audit event reason is `chooser`.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a user has **zero workspace memberships**? → Empty state: "You don't have access to any workspace yet." with optional "Manage workspaces" link (permission-gated).
|
||||
- What happens when the workspace referenced by `last_workspace_id` is **archived**? → Treated as invalid, cleared, chooser shown.
|
||||
- What happens when `?choose=1` is used by a user with **only 1 workspace**? → Chooser is shown anyway (forced mode).
|
||||
- What happens when `session.current_workspace_id` is set but the workspace was **archived** between requests? → Session cleared, warning shown, chooser displayed.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (RBAC-UX):**
|
||||
|
||||
- **Authorization plane**: workspace `/admin` scope.
|
||||
- **Membership = switch-right**: no separate `workspace.switch` capability in v1. Any workspace member may select/switch to that workspace.
|
||||
- **`workspace.manage`** gates: visibility of "Manage workspaces" link in chooser; access to Workspace CRUD (existing separate resource).
|
||||
- **404 vs 403**: non-member attempting to select a workspace they're not in → 404 (deny-as-not-found); results in no selection change.
|
||||
- **Server-side enforcement**: `EnsureActiveWorkspace` middleware validates membership on every request; chooser only lists workspaces with valid membership.
|
||||
|
||||
**Constitution alignment (audit):**
|
||||
|
||||
- Workspace selection events (`workspace.auto_selected`, `workspace.selected`) are logged via `WorkspaceAuditLogger` using new `AuditActionId` enum values.
|
||||
- No `OperationRun` needed — these are session-context changes, not long-running operations.
|
||||
|
||||
**Constitution alignment (UX-001):**
|
||||
|
||||
- The chooser page is a **context selector**, not a CRUD screen. UX-001 layout rules (Main/Aside, Sections) do not directly apply. Exemption: this is a custom selection page, not a Create/Edit/View resource page.
|
||||
- Empty state follows UX-001: specific title + explanation + 1 CTA (permission-gated).
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST auto-resume to an active workspace without showing the chooser when: (a) session is valid, (b) user has exactly 1 membership, or (c) `last_workspace_id` points to a valid membership.
|
||||
- **FR-002**: System MUST show the chooser only when auto-resume cannot determine a valid workspace.
|
||||
- **FR-003**: Chooser MUST display each workspace with: Name, Role badge, Tenants count (`withCount`), and a primary "Open" action.
|
||||
- **FR-004**: System MUST clear stale session/preference values and show a warning notification when membership has been revoked for the referenced workspace.
|
||||
- **FR-005**: System MUST emit an audit event for every workspace selection (auto or manual) with payload: `actor_id`, `workspace_id`, `method`, `reason`, optional `prev_workspace_id`.
|
||||
- **FR-006**: Chooser MUST NOT prominently display "Create workspace". A "Manage workspaces" link MAY appear, gated by `workspace.manage` capability.
|
||||
- **FR-007**: Route parameter `?choose=1` MUST force the chooser to display regardless of auto-resume eligibility.
|
||||
- **FR-008**: User menu MUST contain a "Switch workspace" entry that links to `/admin/choose-workspace?choose=1`. Entry is visible only when the user has >1 workspace membership.
|
||||
- **FR-009**: After workspace selection (auto or manual), the system MUST redirect to the workspace dashboard (no smart redirect in v1).
|
||||
- **FR-010**: The `EnsureActiveWorkspace` middleware MUST run on all `/admin/*` routes except the chooser page itself, login/logout routes, and OAuth callbacks.
|
||||
- **FR-011**: Chooser queries MUST NOT produce N+1 problems (eager load memberships + `withCount('tenants')`).
|
||||
|
||||
---
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| ChooseWorkspace (Custom Page) | `app/Filament/Pages/ChooseWorkspace.php` | None (v1) | N/A — cards/rows | "Open" (primary) per workspace | N/A | "You don't have access to any workspace yet." + "Manage workspaces" (gated by `workspace.manage`) | N/A | N/A | Yes — `workspace.selected` / `workspace.auto_selected` | Context selector page, not CRUD. "Create workspace" removed from header actions; accessible only via "Manage workspaces" link. |
|
||||
|
||||
---
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Workspace**: The organizational context (portfolio/MSP account) that groups Microsoft Tenants. Key attributes: name, slug, archived_at.
|
||||
- **WorkspaceMembership**: Pivot linking User to Workspace with a role. Determines selection eligibility.
|
||||
- **User**: The authenticated actor. Stores `last_workspace_id` as a convenience preference for auto-resume.
|
||||
- **AuditLog**: Existing audit infrastructure. New action IDs for workspace selection events.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users with a single workspace reach their dashboard in **zero extra clicks** after login (no chooser screen).
|
||||
- **SC-002**: Users with a valid last-used workspace reach their dashboard in **zero extra clicks** after login.
|
||||
- **SC-003**: 100% of workspace selection events (auto and manual) produce an audit log entry within the same request.
|
||||
- **SC-004**: Chooser page loads in **under 200ms database time** with up to 50 workspace memberships (no N+1 queries).
|
||||
- **SC-005**: Users whose membership was revoked see a **clear warning message** within 1 page load, never a broken/empty state.
|
||||
- **SC-006**: "Create workspace" is **never visible** on the chooser page as a primary action; only "Manage workspaces" appears for authorized users.
|
||||
|
||||
---
|
||||
|
||||
## Terminology & Copy
|
||||
|
||||
- **"Workspace"** remains the product term (matches architecture: Workspace → contains Tenants).
|
||||
- Chooser page title: **"Select workspace"**
|
||||
- Chooser description: **"A workspace groups one or more Microsoft tenants (customer environments)."**
|
||||
- Warning banner (revoked access): **"Your access to {workspace_name} was removed."**
|
||||
- User menu entry: **"Switch workspace"**
|
||||
- Button label: **"Open"** (not "Continue")
|
||||
|
||||
---
|
||||
|
||||
## Middleware: EnsureActiveWorkspace (v1 Algorithm)
|
||||
|
||||
The middleware runs on all `/admin/*` routes (except chooser, login/logout, OAuth callbacks).
|
||||
|
||||
**Algorithm (strict order):**
|
||||
|
||||
1. If request path is `/admin/choose-workspace` → **allow** (prevent redirect loop).
|
||||
2. If query has `choose=1` → **redirect to chooser**.
|
||||
3. If `session.current_workspace_id` is set:
|
||||
- If membership valid + workspace not archived → **allow**.
|
||||
- Else: clear session, set flash warning ("Your access to {name} was removed."), redirect to chooser.
|
||||
4. Load user's workspace memberships (selectable only: not archived).
|
||||
5. If exactly 1 → set active, emit audit (`auto_selected`, reason: `single_membership`), redirect to dashboard.
|
||||
6. If `users.last_workspace_id` set:
|
||||
- If membership valid + workspace selectable → set active, emit audit (`auto_selected`, reason: `last_used`), redirect to dashboard.
|
||||
- Else: clear `last_workspace_id`, set flash warning, redirect to chooser.
|
||||
7. Else → redirect to chooser.
|
||||
|
||||
---
|
||||
|
||||
## Data Model (v1)
|
||||
|
||||
### Existing (no changes needed)
|
||||
|
||||
- `workspaces` table (name, slug, archived_at)
|
||||
- `workspace_memberships` pivot (workspace_id, user_id, role)
|
||||
- `users.last_workspace_id` (nullable FK) — already exists, used by `WorkspaceContext::setCurrentWorkspace()`
|
||||
- `session.current_workspace_id` — `WorkspaceContext::SESSION_KEY`
|
||||
|
||||
### New
|
||||
|
||||
- **`AuditActionId` enum values**: `WorkspaceAutoSelected`, `WorkspaceSelected` — to be added to existing enum.
|
||||
- No new tables or columns required.
|
||||
|
||||
---
|
||||
|
||||
## Audit Events (v1)
|
||||
|
||||
| Event | AuditActionId | Method | Reason | Payload |
|
||||
|---|---|---|---|---|
|
||||
| Auto-resume: single membership | `workspace.auto_selected` | `auto` | `single_membership` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
|
||||
| Auto-resume: last used | `workspace.auto_selected` | `auto` | `last_used` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
|
||||
| Manual selection from chooser | `workspace.selected` | `manual` | `chooser` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan (Feature/Integration)
|
||||
|
||||
- `it_skips_chooser_when_single_workspace_membership`
|
||||
- `it_auto_resumes_to_last_used_workspace_when_membership_valid`
|
||||
- `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning`
|
||||
- `it_clears_session_when_active_workspace_membership_revoked`
|
||||
- `it_only_lists_workspaces_user_is_member_of`
|
||||
- `it_shows_name_role_and_tenants_count_per_workspace`
|
||||
- `it_persists_last_used_workspace_on_manual_selection`
|
||||
- `it_emits_audit_event_on_auto_selection_single_membership`
|
||||
- `it_emits_audit_event_on_auto_selection_last_used`
|
||||
- `it_emits_audit_event_on_manual_selection`
|
||||
- `it_hides_manage_link_without_workspace_manage_capability`
|
||||
- `it_shows_manage_link_with_workspace_manage_capability`
|
||||
- `it_forces_chooser_with_choose_param`
|
||||
- `it_shows_empty_state_when_no_memberships`
|
||||
- `it_hides_switch_workspace_menu_when_single_workspace`
|
||||
- `it_shows_switch_workspace_menu_when_multiple_workspaces`
|
||||
- `it_has_no_n_plus_1_queries_in_chooser` (query count assertion)
|
||||
|
||||
---
|
||||
|
||||
## v2 Backlog (Explicitly Deferred)
|
||||
|
||||
- Search/Sort/Favorites/Pins in chooser
|
||||
- Environment Badges (Prod/Test/Staging) — requires data source
|
||||
- Last Activity per workspace (max OperationRun timestamp)
|
||||
- Smart Redirect after switch (return to last page if authorized in new workspace)
|
||||
- Stateless API workspace scoping (header/token-based)
|
||||
- Dropdown switcher in header (v1 = link to chooser page)
|
||||
- `user_preferences` JSONB table (only if more preferences accumulate; v1 stays on `users.last_workspace_id`)
|
||||
Loading…
Reference in New Issue
Block a user