TenantAtlas/specs/107-workspace-chooser/spec.md
Ahmed Darrazi d67e2c84bc fix: resolve 5 consistency issues from project analysis (F1–F7)
F1: Replace 'EnsureActiveWorkspace' with 'EnsureWorkspaceSelected' in spec (3 occurrences) — aligns with R1 decision
F2: Add audit logging to SwitchWorkspaceController (T036) + context_bar reason — closes FR-005 gap
F3: Remove stale WorkspaceContext.php MODIFY from plan project structure
F4/F5: Add WorkspaceRedirectResolver.php + 2 missing test files to plan project structure
F6: Add single-workspace sub-case (EC3) note to T030
F7: Add archived workspace scenario (EC2) note to T024
2026-02-22 15:18:26 +01:00

18 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.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.
  • What happens when a user has multiple browser tabs open and switches workspace in one tab? → Session is the single source of truth. Other tabs reflect the new workspace on their next server request. No per-tab isolation in v1.

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: EnsureWorkspaceSelected 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 apply existing tenant-count branching: 0 tenants → Managed Tenants index, 1 tenant → Tenant Dashboard directly, >1 tenants → Choose Tenant page. No "smart redirect" to the last-visited page in v1.
  • FR-010: The EnsureWorkspaceSelected 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: EnsureWorkspaceSelected (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-workspaceallow (prevent redirect loop).
  2. If query has choose=1redirect 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 via tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant).
  6. If users.last_workspace_id set:
    • If membership valid + workspace selectable → set active, emit audit (auto_selected, reason: last_used), redirect via tenant-count branching.
    • 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_idWorkspaceContext::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)
Context-bar switch (dropdown) workspace.selected manual context_bar 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)

Clarifications

Session 2026-02-22

  • Q: Should v1 redirect always to a fixed dashboard, or preserve existing tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant)? → A: Preserve existing tenant-count branching — avoids UX regression for current users.
  • Q: Should the middleware treat the session as single source of truth for all tabs, or add per-tab workspace isolation? → A: Session is single source of truth — all tabs share the workspace context. Switching in one tab is reflected in all others on next request. Matches existing WorkspaceContext design.

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)