17 KiB
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.managecapability 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) — managessession.current_workspace_id,users.last_workspace_id, and providesresolveInitialWorkspaceFor()with partial auto-resume logic (session → last-used → single membership).ChooseWorkspacepage (app/Filament/Pages/ChooseWorkspace.php) — card grid with "Create workspace" header action, Livewire select, and POST-based form submit.WorkspaceMembershippivot model withrolecolumn.- Audit system via
WorkspaceAuditLogger+AuditActionIdenum (workspace membership events already audited). Capabilities::WORKSPACE_MANAGEalready defined in the capability registry.
What's Missing (Motivation)
- Switch vs. Manage conflation: "Create workspace" is prominently placed in the chooser alongside selection.
- No explicit auto-resume in middleware:
resolveInitialWorkspaceFor()exists but is not systematically called as middleware before every/admin/*request. - No race-condition handling with user feedback: stale
last_workspace_idor revoked membership causes silent fallback, no warning notification. - Minimal metadata: chooser cards show name + "Last used" badge only — no role, no tenant count.
- 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:
- Given a user with exactly 1 workspace membership and no session, When they visit
/admin, Then they are redirected directly to the workspace dashboard andsession.current_workspace_idis set. - Given a user with exactly 1 workspace membership, When auto-resume fires, Then an audit event
workspace.auto_selectedwith reasonsingle_membershipis 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:
- Given a user with 3 workspace memberships and
last_workspace_idpointing to a valid membership, When they visit/admin, Then they land on that workspace's dashboard. - Given the auto-resume via last-used fires, Then an audit event
workspace.auto_selectedwith reasonlast_usedis 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:
- Given a user with 3 workspace memberships and no
last_workspace_id, When they visit/admin, Then the chooser is displayed. - Given the chooser renders, Then each workspace row shows: Name, Role badge, Tenants count, and an "Open" action.
- Given the chooser renders, Then "Create workspace" is not prominently shown. A "Manage workspaces" link appears only if the user has
workspace.managecapability.
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:
- Given a user with
session.current_workspace_idpointing 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." - Given a user with
last_workspace_idpointing to a revoked membership and no session, When they visit/admin, Thenlast_workspace_idis 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:
- 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. - Given a user selects a different workspace from the chooser, Then
session.current_workspace_idis updated,users.last_workspace_idis updated, and an audit eventworkspace.selectedwith reasonchooseris emitted. - 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:
- Given any workspace selection occurs, Then an audit log entry is created with:
actor_id,workspace_id,method(auto|manual),reason, and optionalprev_workspace_id. - Given an auto-resume via single membership, Then the audit event reason is
single_membership. - 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_idis archived? → Treated as invalid, cleared, chooser shown. - What happens when
?choose=1is used by a user with only 1 workspace? → Chooser is shown anyway (forced mode). - What happens when
session.current_workspace_idis set but the workspace was archived between requests? → Session cleared, warning shown, chooser displayed.
Requirements (mandatory)
Constitution alignment (RBAC-UX):
- Authorization plane: workspace
/adminscope. - Membership = switch-right: no separate
workspace.switchcapability in v1. Any workspace member may select/switch to that workspace. workspace.managegates: 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:
EnsureActiveWorkspacemiddleware 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 viaWorkspaceAuditLoggerusing newAuditActionIdenum values. - No
OperationRunneeded — 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_idpoints 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, optionalprev_workspace_id. - FR-006: Chooser MUST NOT prominently display "Create workspace". A "Manage workspaces" link MAY appear, gated by
workspace.managecapability. - FR-007: Route parameter
?choose=1MUST 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
EnsureActiveWorkspacemiddleware 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_idas 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):
- If request path is
/admin/choose-workspace→ allow (prevent redirect loop). - If query has
choose=1→ redirect to chooser. - If
session.current_workspace_idis set:- If membership valid + workspace not archived → allow.
- Else: clear session, set flash warning ("Your access to {name} was removed."), redirect to chooser.
- Load user's workspace memberships (selectable only: not archived).
- If exactly 1 → set active, emit audit (
auto_selected, reason:single_membership), redirect to dashboard. - If
users.last_workspace_idset:- 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.
- If membership valid + workspace selectable → set active, emit audit (
- Else → redirect to chooser.
Data Model (v1)
Existing (no changes needed)
workspacestable (name, slug, archived_at)workspace_membershipspivot (workspace_id, user_id, role)users.last_workspace_id(nullable FK) — already exists, used byWorkspaceContext::setCurrentWorkspace()session.current_workspace_id—WorkspaceContext::SESSION_KEY
New
AuditActionIdenum 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_membershipit_auto_resumes_to_last_used_workspace_when_membership_validit_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warningit_clears_session_when_active_workspace_membership_revokedit_only_lists_workspaces_user_is_member_ofit_shows_name_role_and_tenants_count_per_workspaceit_persists_last_used_workspace_on_manual_selectionit_emits_audit_event_on_auto_selection_single_membershipit_emits_audit_event_on_auto_selection_last_usedit_emits_audit_event_on_manual_selectionit_hides_manage_link_without_workspace_manage_capabilityit_shows_manage_link_with_workspace_manage_capabilityit_forces_chooser_with_choose_paramit_shows_empty_state_when_no_membershipsit_hides_switch_workspace_menu_when_single_workspaceit_shows_switch_workspace_menu_when_multiple_workspacesit_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_preferencesJSONB table (only if more preferences accumulate; v1 stays onusers.last_workspace_id)