- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration - Story 1.2: Team page with member list, invitation system with queued email - Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging - Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal) - Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping - Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching - 83 new/modified files, 182 tests passing with zero regressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
Story 1.5: Role-Based Access Enforcement Across Views
Status: done
Story
As a firm worker, I want to see only my assigned clients and declarations when I navigate the platform, so that I can focus on my work without being overwhelmed by the entire firm's data.
Acceptance Criteria
- Workers navigating to Clients page see only clients that have at least one declaration with
assigned_to= their user ID; client count reflects scoped view - Workers navigating to Declarations page see only declarations where
assigned_to= their user ID;Declaration::scopeForUser()applies: Workers getwhere('assigned_to', $userId), Owners/Managers get unscoped - Owners/Managers see all workspace items on all pages with no scoping restrictions
- Workers accessing a declaration not assigned to them via direct URL receive 404 (not 403)
- Workers accessing a client that has zero declarations assigned to them via direct URL receive 404
DeclarationController,ClientController, and all existing controllers applyHasWorkspaceScopetrait and role scoping consistentlyActivity log viewing is scoped: Owners see all, Managers see all if(Deferred to Epic 2 — Dashboard activity feed; no activity log page exists yet)can_view_activity_logsis true (else 404), Workers see only their own actions- Sidebar navigation adapts per role: Owner/Manager sees "Dashboard, Clients, Declarations, Team, Settings"; Worker sees "Dashboard, My Declarations, Settings"
HandleInertiaRequestsshares the current user's workspace role globally viaauth.workspaceRole- Frontend conditionally hides create/edit/delete action buttons for Workers on Clients and Declarations pages
Tasks / Subtasks
-
Task 1: Backend — Share workspace role in HandleInertiaRequests (AC: #9)
- 1.1 In
HandleInertiaRequests::share(), resolve the current user'sWorkspaceUserpivot and shareauth.workspaceRole(string:'owner'|'manager'|'worker'|null) - 1.2 Update
resources/js/types/auth.ts— addworkspaceRole?: 'owner' | 'manager' | 'worker' | nulltoAuthtype
- 1.1 In
-
Task 2: Backend — Add
scopeForUser()to Declaration model (AC: #2)- 2.1 Add
scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builderon Declaration model - 2.2 Logic: if role is Worker →
$query->where('assigned_to', $user->id); else return unmodified$query - 2.3 Use
$workspaceUser->role->is(WorkspaceUserRole::Worker)for comparison (bensampo->is()pattern)
- 2.1 Add
-
Task 3: Backend — Refactor ClientController to use traits and role scoping (AC: #1, #4, #5, #6, #10)
- 3.1 Add
use HasWorkspaceScope, AuthorizesPermissions;traits to ClientController - 3.2 Remove the manual
currentWorkspace()method (replaced by trait) - 3.3 In
index(): for Workers, scope clients query to only those having at least one declaration assigned to the worker:$workspace->clients()->whereHas('declarations', fn($q) => $q->where('assigned_to', $user->id)); Owners/Managers get unscoped - 3.4 In
show(),edit(),update(),destroy(): for Workers, verify the client has at least one declaration assigned to them, elseabort(404) - 3.5 In
create()andstore(): Workers cannot create clients —abort(404)if Worker role - 3.6 Pass
canCreate,canEdit,canDeleteboolean props to frontend views (false for Workers) - 3.7 Remove manual
authorizeClient()method — replace withauthorizeWorkspaceAccess()from trait + role check
- 3.1 Add
-
Task 4: Backend — Refactor DeclarationController to use traits and role scoping (AC: #2, #3, #4, #6, #10)
- 4.1 Add
use HasWorkspaceScope, AuthorizesPermissions;traits to DeclarationController - 4.2 Remove the manual
currentWorkspace()method (replaced by trait) - 4.3 In
index(): applyDeclaration::scopeForUser()to scope worker queries - 4.4 In
show(): for Workers, verify$declaration->assigned_to === auth()->id(), elseabort(404) - 4.5 In
edit(),update(): Workers cannot edit declarations —abort(404)if Worker role - 4.6 In
create(),store(): Workers cannot create declarations —abort(404)if Worker role - 4.7 In
destroy(): Workers cannot delete declarations —abort(404)if Worker role - 4.8 Pass
canCreate,canEdit,canDeleteboolean props to frontend views - 4.9 Remove manual
authorizeDeclaration()method — replace withauthorizeWorkspaceAccess()+ role check - 4.10 Replace inline
canMentionrole check inshow()withAuthorizesPermissionspattern
- 4.1 Add
-
Task 5: Frontend — Role-based sidebar navigation (AC: #8)
- 5.1 In
AppSidebar.vue, readauth.workspaceRolefromusePage()props - 5.2 For Worker role: show only "Dashboard" and "Mes declarations" (href:
/declarations) items; hide "Clients" and "Team" nav items - 5.3 For Owner/Manager: show full nav (Dashboard, Clients, Declarations, Team)
- 5.4 Rename "Declarations" to "Mes declarations" in nav label when role is Worker
- 5.1 In
-
Task 6: Frontend — Conditional action buttons on Clients pages (AC: #10)
- 6.1 In
clients/Index.vue: hide "Add Client" button whencanCreateis false - 6.2 In
clients/Index.vue: hide edit/delete action links per row whencanEdit/canDeleteare false - 6.3 In
clients/Edit.vue/clients/Create.vue: these pages are not accessible by Workers (backend returns 404), no frontend changes needed
- 6.1 In
-
Task 7: Frontend — Conditional action buttons on Declarations pages (AC: #10)
- 7.1 In
declarations/Index.vue: hide "New Declaration" button whencanCreateis false - 7.2 In
declarations/Index.vue: hide edit/delete action links per row whencanEdit/canDeleteare false - 7.3 In
declarations/Show.vue: hide edit button and mention capability when Worker
- 7.1 In
-
Task 8: Tests — Comprehensive role-based access enforcement tests (AC: #1-#10)
- 8.1 Test Worker sees only assigned declarations in index (scoped query)
- 8.2 Test Worker sees only clients with assigned declarations in clients index
- 8.3 Test Owner/Manager sees all declarations and clients (unscoped)
- 8.4 Test Worker gets 404 accessing unassigned declaration via direct URL
- 8.5 Test Worker gets 404 accessing client with no assigned declarations
- 8.6 Test Worker gets 404 on create/store/edit/update/destroy for clients
- 8.7 Test Worker gets 404 on create/store/edit/update/destroy for declarations
- 8.8 Test Manager can access all CRUD operations on clients and declarations
- 8.9 Test Owner can access all CRUD operations on clients and declarations
- 8.10 Test
auth.workspaceRoleis shared correctly in Inertia props for each role - 8.11 Test cross-workspace isolation: Worker in workspace A cannot see declarations in workspace B
Dev Notes
Critical Architecture Patterns (from Stories 1.1-1.4)
- Authorization:
abort(404)NEVERabort(403)— hides workspace existence per project convention (NFR9) - Enum comparisons: use
->is()with bensampo/laravel-enum, NOT=== - WorkspaceUser extends Pivot — route model binding does NOT work; use manual
WorkspaceUser::where()lookup scoped to current workspace via session - Permission checking: Role-based with JSON column on
workspace_userpivot. NO Spatie policies/gates - Activity logging: Use
activity()helper for business operations (non-model-level) - Frontend URLs: All URLs via
route()helper passed as Inertia props — never hardcode routes in Vue - Transactions: Wrap multi-step mutations in
DB::transaction() - Model casts: Use
protected function casts(): arraymethod, NOT$castsproperty - Flash messages:
HandleInertiaRequestssharessuccess/errorflash keys;AppSidebarLayoutdisplays toast - Form Requests: Always use dedicated Form Request classes for validation, never inline
$request->validate() - Controller authorization: Custom
authorizeXxx()protected methods, NO Gates/Policies - Inertia render paths: Lowercase subdirectory:
'clients/Index', not'Clients/Index'
Existing Permission Infrastructure (Built in Stories 1.1-1.4)
| Component | Location | Purpose |
|---|---|---|
Permission enum |
app/Enums/Permission.php |
3 permissions: CanManageTeam, CanViewActivityLogs, CanConfigurePortal |
WorkspaceUserRole enum |
app/Enums/WorkspaceUserRole.php |
3 roles: Owner, Manager, Worker |
config/permissions.php |
Config file | Default permissions per role |
AuthorizesPermissions trait |
app/Concerns/AuthorizesPermissions.php |
authorizePermission() — Owner always passes, Worker always 404, Manager checks JSON |
HasWorkspaceScope trait |
app/Concerns/HasWorkspaceScope.php |
currentWorkspace() and authorizeWorkspaceAccess() |
WorkspaceUser model |
app/Models/WorkspaceUser.php |
Pivot with role + permissions JSON columns |
TeamController |
app/Http/Controllers/TeamController.php |
Reference implementation — uses both traits, full role enforcement |
Current State of Controllers (What Needs Changing)
ClientController (app/Http/Controllers/ClientController.php):
- Does NOT use
AuthorizesPermissionsorHasWorkspaceScopetraits - Has its own
currentWorkspace()method (duplicated, should use trait) - Has
authorizeClient()that only checks workspace_id match (no role scoping) index()returns ALL workspace clients — no Worker filtering- No
canCreate/canEdit/canDeleteprops passed to frontend
DeclarationController (app/Http/Controllers/DeclarationController.php):
- Does NOT use
AuthorizesPermissionsorHasWorkspaceScopetraits - Has its own
currentWorkspace()method (duplicated, should use trait) - Has
authorizeDeclaration()that only checks workspace_id match (no role scoping) index()returns ALL workspace declarations — no Worker filteringshow()has inlinecanMentionrole check (lines 239-242) — should use trait pattern- No
canCreate/canEdit/canDeleteprops passed to frontend
Database Schema (Existing — No New Migrations Needed)
declarations table:
assigned_to(nullable FK → users.id) — Worker assignment columnworkspace_id(FK → workspaces.id)created_by(FK → users.id)
clients table:
internal_responsible_id(nullable FK → users.id) — NOT used for Worker scopingworkspace_id(FK → workspaces.id)- Workers see clients via declaration assignment, NOT via
internal_responsible_id
workspace_user pivot table:
role(enum: owner/manager/worker)permissions(JSON column)
Worker Scoping Logic (Critical)
Declarations: Simple — where('assigned_to', $userId) via scopeForUser()
Clients: Indirect — Workers see clients that have at least one declaration assigned to them:
$workspace->clients()->whereHas('declarations', fn ($q) => $q->where('assigned_to', $user->id))
Single Resource Access (show/edit/destroy):
- Declaration: verify
$declaration->assigned_to === auth()->id()for Workers - Client: verify client has at least one declaration with
assigned_to === auth()->id()for Workers
HandleInertiaRequests Changes
Current auth shared prop structure:
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
]
Must add workspaceRole:
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
'workspaceRole' => $user ? $user->workspaces()
->where('workspaces.id', $currentWorkspaceId)
->first()?->pivot?->role?->value : null,
]
Frontend Auth Type Update
Add to resources/js/types/auth.ts:
export type Auth = {
user: User | null;
workspaces?: Workspace[];
currentWorkspace?: Workspace | null;
workspaceRole?: 'owner' | 'manager' | 'worker' | null; // NEW
};
Sidebar Navigation Per Role
| Role | Nav Items |
|---|---|
| Owner/Manager | Dashboard, Clients, Declarations, Team (if can_manage_team or Owner) |
| Worker | Dashboard, Mes declarations |
Note: Team nav item is already gated by the Team page itself (Workers get 404). But hiding it from the sidebar prevents confusion. Settings is accessed via the user menu, not the sidebar.
AppSidebar.vue Changes
Current sidebar at resources/js/components/AppSidebar.vue:
- Lines 39-52: Shows Clients + Declarations if
currentWorkspaceexists — needs role gating - Uses
page.props.auth?.currentWorkspace— will also usepage.props.auth?.workspaceRole - Already conditionally shows admin nav items based on
page.props.auth.user?.group - Team nav item is NOT in the sidebar currently — it's accessed via the workspace settings/team page
What This Story Does NOT Include
- No changes to the Team page (already fully authorized in Stories 1.1-1.4)
- No new database migrations
- No changes to settings pages (accessible to all roles)
- No activity log viewing page (AC #7 — deferred until Epic 2 Dashboard, where activity feed is implemented; the scoping logic should be noted for that story)
- No changes to client portal routes (
/c/*) — those use token-based middleware, not auth
Project Structure Notes
- Alignment with established patterns from TeamController (reference implementation)
- Both
HasWorkspaceScopeandAuthorizesPermissionstraits are already built and tested - No new Vue pages needed — modify existing Index/Show pages
- No new models or migrations — uses existing
assigned_toandworkspace_user.rolecolumns - Test files:
tests/Feature/Clients/RoleBasedAccessTest.phpandtests/Feature/Declarations/RoleBasedAccessTest.php
References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.5]
- [Source: _bmad-output/planning-artifacts/prd.md#FR10 — "System enforces role-based access — Workers see only assigned items, Managers/Owners see all"]
- [Source: _bmad-output/planning-artifacts/architecture.md#Permission Checking Patterns]
- [Source: _bmad-output/planning-artifacts/architecture.md#Workspace & Tenant Scoping Patterns]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Collapsible sidebar with role-driven sections]
- [Source: _bmad-output/implementation-artifacts/1-4-manager-permission-toggle-matrix.md]
- [Source: app/Concerns/AuthorizesPermissions.php]
- [Source: app/Concerns/HasWorkspaceScope.php]
- [Source: app/Http/Controllers/TeamController.php — reference implementation]
- [Source: app/Http/Controllers/ClientController.php — needs refactoring]
- [Source: app/Http/Controllers/DeclarationController.php — needs refactoring]
- [Source: app/Http/Middleware/HandleInertiaRequests.php — needs workspaceRole]
- [Source: app/Models/Declaration.php — needs scopeForUser()]
- [Source: resources/js/components/AppSidebar.vue — needs role-based nav]
- [Source: resources/js/types/auth.ts — needs workspaceRole field]
Previous Story Intelligence
From Story 1.4 (most recent):
AuthorizesPermissionstrait works: Owner always passes, Worker always 404, Manager checks JSON permissionsHasWorkspaceScopetrait providescurrentWorkspace()andauthorizeWorkspaceAccess()— ready to adopt in ClientController/DeclarationControllerWorkspaceUser::where('workspace_id', $workspace->id)->where('user_id', $userId)->firstOrFail()pattern for pivot lookup- Role comparison: use
$workspaceUser->role->is(WorkspaceUserRole::Worker)— never=== - Frontend patterns: Dialog, DropdownMenu, Switch from shadcn-vue;
router.put()withpreserveScroll: true - Loading states on action buttons to prevent double-click
- Flash message toast pattern via
HandleInertiaRequests
From Story 1.3:
- Role change resets permissions to defaults — confirms
config('permissions.defaults.{role}')pattern works - Activity logging:
activity()->performedOn($target)->causedBy(auth()->user())->withProperties([...])->log('desc')
From Story 1.2:
canManageTeamprop passed from TeamController to frontend — same pattern forcanCreate/canEdit/canDelete- Team member data includes
workspace_user_idfor URL construction
Git Intelligence:
- All Epic 0 + Stories 1.1-1.4 implemented on branch
l-ami-fiduciaire-v1.0.0 - 144 tests pass (382 assertions) — zero regressions baseline
- PHP Pint linting clean; ESLint errors are pre-existing in unchanged files
Technology Versions
- Laravel 12, PHP 8.2+, Vue 3.5, TypeScript 5.2 (strict), Inertia.js 2.0
- Pest 4.4 for testing
- bensampo/laravel-enum ^6.12 for enums
- Spatie Activity Log ^4.12 for audit trail
- shadcn-vue (reka-ui) for UI components
- lucide-vue-next for icons
- Laravel Wayfinder ^0.1.9 for type-safe frontend routes
Dev Agent Record
Agent Model Used
Claude Opus 4.6
Debug Log References
- Fixed MediaDownloadTest regression: changed
otherUserrole fromworkertomanagersince the test is about per-user download tracking, not role access. - Test data for store/update tests required valid payloads to pass Form Request validation before reaching controller Worker checks.
Completion Notes List
- Implemented
auth.workspaceRolesharing viaHandleInertiaRequestsmiddleware usingWorkspaceUserpivot lookup - Added
scopeForUser()scope to Declaration model — Workers see only assigned declarations, Owners/Managers see all - Refactored
ClientControllerandDeclarationControllerto useHasWorkspaceScopetrait, removing duplicatedcurrentWorkspace()and manual authorization methods - Workers get
abort(404)on all create/store/edit/update/destroy operations for both clients and declarations - Worker client scoping:
whereHas('declarations', fn($q) => $q->where('assigned_to', $user->id)) - Sidebar adapts per role: Workers see Dashboard + "Mes declarations"; Owners/Managers see Dashboard, Clients, Declarations, Equipe
- Frontend
canCreate/canEdit/canDeleteprops conditionally hide action buttons for Workers on Index, Show pages - 32 new tests covering 10 of 11 acceptance criteria including cross-workspace isolation (AC #7 deferred to Epic 2)
- Full suite: 176 tests passing (638 assertions), zero regressions
File List
- app/Http/Middleware/HandleInertiaRequests.php (modified — added workspaceRole to auth shared props)
- app/Models/Declaration.php (modified — added scopeForUser() scope)
- app/Models/User.php (modified — memoized currentWorkspaceUser() to avoid duplicate queries per request)
- app/Http/Controllers/ClientController.php (modified — refactored with traits, role scoping, canCreate/canEdit/canDelete props, Worker-scoped declarations/stats in show)
- app/Http/Controllers/DeclarationController.php (modified — refactored with traits, role scoping, canCreate/canEdit/canDelete/canMention props)
- resources/js/types/auth.ts (modified — added workspaceRole to Auth type)
- resources/js/components/AppSidebar.vue (modified — role-based navigation with Wayfinder route helpers)
- resources/js/pages/clients/Index.vue (modified — conditional action buttons)
- resources/js/pages/declarations/Index.vue (modified — conditional action buttons)
- resources/js/pages/declarations/Show.vue (modified — conditional edit button and canEdit/canDelete props)
- tests/Feature/Client/RoleBasedAccessTest.php (new — 14 tests for client role-based access)
- tests/Feature/Declaration/RoleBasedAccessTest.php (new — 18 tests for declaration role-based access)
- tests/Feature/Declaration/MediaDownloadTest.php (modified — fixed otherUser role for compatibility)
Change Log
- 2026-03-15: Implemented Story 1.5 — Role-based access enforcement across all views. Workers see only assigned items, Owners/Managers see all. Sidebar and action buttons adapt per role. 32 new tests added.
- 2026-03-15: Code review fixes — memoized
currentWorkspaceUser()to eliminate N+1 queries, scoped client show declarations list and stats for Workers (information leak fix), replaced hardcoded sidebar URLs with Wayfinder route helpers, removed unusedAuthorizesPermissionstrait from both controllers, clarified AC #7 deferral.