From 4b285104da90826978d70ea87785a291d9c8d47e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 22 Feb 2026 14:29:20 +0100 Subject: [PATCH] =?UTF-8?q?spec:=20107=20=E2=80=94=20Workspace=20Chooser?= =?UTF-8?q?=20v1=20(Enterprise)=20+=20In-App=20Switch=20Entry=20Point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checklists/requirements.md | 47 +++ specs/107-workspace-chooser/spec.md | 300 ++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 specs/107-workspace-chooser/checklists/requirements.md create mode 100644 specs/107-workspace-chooser/spec.md diff --git a/specs/107-workspace-chooser/checklists/requirements.md b/specs/107-workspace-chooser/checklists/requirements.md new file mode 100644 index 0000000..957ebc2 --- /dev/null +++ b/specs/107-workspace-chooser/checklists/requirements.md @@ -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`. diff --git a/specs/107-workspace-chooser/spec.md b/specs/107-workspace-chooser/spec.md new file mode 100644 index 0000000..de2dbf3 --- /dev/null +++ b/specs/107-workspace-chooser/spec.md @@ -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`) \ No newline at end of file