Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/1-5-role-based-access-enforcement-across-views.md
Saad Ibn-Ezzoubayr c89d1879bf feat: complete Epic 1 — team management & permission system
- 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>
2026-03-18 00:12:50 +00:00

325 lines
20 KiB
Markdown

# Story 1.5: Role-Based Access Enforcement Across Views
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## 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
1. 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
2. Workers navigating to Declarations page see only declarations where `assigned_to` = their user ID; `Declaration::scopeForUser()` applies: Workers get `where('assigned_to', $userId)`, Owners/Managers get unscoped
3. Owners/Managers see all workspace items on all pages with no scoping restrictions
4. Workers accessing a declaration not assigned to them via direct URL receive 404 (not 403)
5. Workers accessing a client that has zero declarations assigned to them via direct URL receive 404
6. `DeclarationController`, `ClientController`, and all existing controllers apply `HasWorkspaceScope` trait and role scoping consistently
7. ~~Activity log viewing is scoped: Owners see all, Managers see all if `can_view_activity_logs` is true (else 404), Workers see only their own actions~~ *(Deferred to Epic 2 — Dashboard activity feed; no activity log page exists yet)*
8. Sidebar navigation adapts per role: Owner/Manager sees "Dashboard, Clients, Declarations, Team, Settings"; Worker sees "Dashboard, My Declarations, Settings"
9. `HandleInertiaRequests` shares the current user's workspace role globally via `auth.workspaceRole`
10. Frontend conditionally hides create/edit/delete action buttons for Workers on Clients and Declarations pages
## Tasks / Subtasks
- [x] Task 1: Backend — Share workspace role in HandleInertiaRequests (AC: #9)
- [x] 1.1 In `HandleInertiaRequests::share()`, resolve the current user's `WorkspaceUser` pivot and share `auth.workspaceRole` (string: `'owner'|'manager'|'worker'|null`)
- [x] 1.2 Update `resources/js/types/auth.ts` — add `workspaceRole?: 'owner' | 'manager' | 'worker' | null` to `Auth` type
- [x] Task 2: Backend — Add `scopeForUser()` to Declaration model (AC: #2)
- [x] 2.1 Add `scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builder` on Declaration model
- [x] 2.2 Logic: if role is Worker → `$query->where('assigned_to', $user->id)`; else return unmodified `$query`
- [x] 2.3 Use `$workspaceUser->role->is(WorkspaceUserRole::Worker)` for comparison (bensampo `->is()` pattern)
- [x] Task 3: Backend — Refactor ClientController to use traits and role scoping (AC: #1, #4, #5, #6, #10)
- [x] 3.1 Add `use HasWorkspaceScope, AuthorizesPermissions;` traits to ClientController
- [x] 3.2 Remove the manual `currentWorkspace()` method (replaced by trait)
- [x] 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
- [x] 3.4 In `show()`, `edit()`, `update()`, `destroy()`: for Workers, verify the client has at least one declaration assigned to them, else `abort(404)`
- [x] 3.5 In `create()` and `store()`: Workers cannot create clients — `abort(404)` if Worker role
- [x] 3.6 Pass `canCreate`, `canEdit`, `canDelete` boolean props to frontend views (false for Workers)
- [x] 3.7 Remove manual `authorizeClient()` method — replace with `authorizeWorkspaceAccess()` from trait + role check
- [x] Task 4: Backend — Refactor DeclarationController to use traits and role scoping (AC: #2, #3, #4, #6, #10)
- [x] 4.1 Add `use HasWorkspaceScope, AuthorizesPermissions;` traits to DeclarationController
- [x] 4.2 Remove the manual `currentWorkspace()` method (replaced by trait)
- [x] 4.3 In `index()`: apply `Declaration::scopeForUser()` to scope worker queries
- [x] 4.4 In `show()`: for Workers, verify `$declaration->assigned_to === auth()->id()`, else `abort(404)`
- [x] 4.5 In `edit()`, `update()`: Workers cannot edit declarations — `abort(404)` if Worker role
- [x] 4.6 In `create()`, `store()`: Workers cannot create declarations — `abort(404)` if Worker role
- [x] 4.7 In `destroy()`: Workers cannot delete declarations — `abort(404)` if Worker role
- [x] 4.8 Pass `canCreate`, `canEdit`, `canDelete` boolean props to frontend views
- [x] 4.9 Remove manual `authorizeDeclaration()` method — replace with `authorizeWorkspaceAccess()` + role check
- [x] 4.10 Replace inline `canMention` role check in `show()` with `AuthorizesPermissions` pattern
- [x] Task 5: Frontend — Role-based sidebar navigation (AC: #8)
- [x] 5.1 In `AppSidebar.vue`, read `auth.workspaceRole` from `usePage()` props
- [x] 5.2 For Worker role: show only "Dashboard" and "Mes declarations" (href: `/declarations`) items; hide "Clients" and "Team" nav items
- [x] 5.3 For Owner/Manager: show full nav (Dashboard, Clients, Declarations, Team)
- [x] 5.4 Rename "Declarations" to "Mes declarations" in nav label when role is Worker
- [x] Task 6: Frontend — Conditional action buttons on Clients pages (AC: #10)
- [x] 6.1 In `clients/Index.vue`: hide "Add Client" button when `canCreate` is false
- [x] 6.2 In `clients/Index.vue`: hide edit/delete action links per row when `canEdit`/`canDelete` are false
- [x] 6.3 In `clients/Edit.vue` / `clients/Create.vue`: these pages are not accessible by Workers (backend returns 404), no frontend changes needed
- [x] Task 7: Frontend — Conditional action buttons on Declarations pages (AC: #10)
- [x] 7.1 In `declarations/Index.vue`: hide "New Declaration" button when `canCreate` is false
- [x] 7.2 In `declarations/Index.vue`: hide edit/delete action links per row when `canEdit`/`canDelete` are false
- [x] 7.3 In `declarations/Show.vue`: hide edit button and mention capability when Worker
- [x] Task 8: Tests — Comprehensive role-based access enforcement tests (AC: #1-#10)
- [x] 8.1 Test Worker sees only assigned declarations in index (scoped query)
- [x] 8.2 Test Worker sees only clients with assigned declarations in clients index
- [x] 8.3 Test Owner/Manager sees all declarations and clients (unscoped)
- [x] 8.4 Test Worker gets 404 accessing unassigned declaration via direct URL
- [x] 8.5 Test Worker gets 404 accessing client with no assigned declarations
- [x] 8.6 Test Worker gets 404 on create/store/edit/update/destroy for clients
- [x] 8.7 Test Worker gets 404 on create/store/edit/update/destroy for declarations
- [x] 8.8 Test Manager can access all CRUD operations on clients and declarations
- [x] 8.9 Test Owner can access all CRUD operations on clients and declarations
- [x] 8.10 Test `auth.workspaceRole` is shared correctly in Inertia props for each role
- [x] 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)` NEVER `abort(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_user` pivot. 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(): array` method, NOT `$casts` property
- **Flash messages:** `HandleInertiaRequests` shares `success`/`error` flash keys; `AppSidebarLayout` displays 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 `AuthorizesPermissions` or `HasWorkspaceScope` traits
- 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`/`canDelete` props passed to frontend
**DeclarationController** (`app/Http/Controllers/DeclarationController.php`):
- Does NOT use `AuthorizesPermissions` or `HasWorkspaceScope` traits
- 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 filtering
- `show()` has inline `canMention` role check (lines 239-242) — should use trait pattern
- No `canCreate`/`canEdit`/`canDelete` props passed to frontend
### Database Schema (Existing — No New Migrations Needed)
**declarations table:**
- `assigned_to` (nullable FK → users.id) — Worker assignment column
- `workspace_id` (FK → workspaces.id)
- `created_by` (FK → users.id)
**clients table:**
- `internal_responsible_id` (nullable FK → users.id) — NOT used for Worker scoping
- `workspace_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:
```php
$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:
```php
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
]
```
Must add `workspaceRole`:
```php
'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`:
```typescript
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 `currentWorkspace` exists — needs role gating
- Uses `page.props.auth?.currentWorkspace` — will also use `page.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 `HasWorkspaceScope` and `AuthorizesPermissions` traits are already built and tested
- No new Vue pages needed — modify existing Index/Show pages
- No new models or migrations — uses existing `assigned_to` and `workspace_user.role` columns
- Test files: `tests/Feature/Clients/RoleBasedAccessTest.php` and `tests/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):**
- `AuthorizesPermissions` trait works: Owner always passes, Worker always 404, Manager checks JSON permissions
- `HasWorkspaceScope` trait provides `currentWorkspace()` and `authorizeWorkspaceAccess()` — ready to adopt in ClientController/DeclarationController
- `WorkspaceUser::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()` with `preserveScroll: 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:**
- `canManageTeam` prop passed from TeamController to frontend — same pattern for `canCreate`/`canEdit`/`canDelete`
- Team member data includes `workspace_user_id` for 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 `otherUser` role from `worker` to `manager` since 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.workspaceRole` sharing via `HandleInertiaRequests` middleware using `WorkspaceUser` pivot lookup
- Added `scopeForUser()` scope to Declaration model — Workers see only assigned declarations, Owners/Managers see all
- Refactored `ClientController` and `DeclarationController` to use `HasWorkspaceScope` trait, removing duplicated `currentWorkspace()` 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`/`canDelete` props 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 unused `AuthorizesPermissions` trait from both controllers, clarified AC #7 deferral.