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

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

  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

  • Task 1: Backend — Share workspace role in HandleInertiaRequests (AC: #9)

    • 1.1 In HandleInertiaRequests::share(), resolve the current user's WorkspaceUser pivot and share auth.workspaceRole (string: 'owner'|'manager'|'worker'|null)
    • 1.2 Update resources/js/types/auth.ts — add workspaceRole?: 'owner' | 'manager' | 'worker' | null to Auth type
  • Task 2: Backend — Add scopeForUser() to Declaration model (AC: #2)

    • 2.1 Add scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builder on 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)
  • 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, else abort(404)
    • 3.5 In create() and store(): Workers cannot create clients — abort(404) if Worker role
    • 3.6 Pass canCreate, canEdit, canDelete boolean props to frontend views (false for Workers)
    • 3.7 Remove manual authorizeClient() method — replace with authorizeWorkspaceAccess() from trait + role check
  • 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(): apply Declaration::scopeForUser() to scope worker queries
    • 4.4 In show(): for Workers, verify $declaration->assigned_to === auth()->id(), else abort(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, canDelete boolean props to frontend views
    • 4.9 Remove manual authorizeDeclaration() method — replace with authorizeWorkspaceAccess() + role check
    • 4.10 Replace inline canMention role check in show() with AuthorizesPermissions pattern
  • Task 5: Frontend — Role-based sidebar navigation (AC: #8)

    • 5.1 In AppSidebar.vue, read auth.workspaceRole from usePage() 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
  • Task 6: Frontend — Conditional action buttons on Clients pages (AC: #10)

    • 6.1 In clients/Index.vue: hide "Add Client" button when canCreate is false
    • 6.2 In clients/Index.vue: hide edit/delete action links per row when canEdit/canDelete are false
    • 6.3 In clients/Edit.vue / clients/Create.vue: these pages are not accessible by Workers (backend returns 404), no frontend changes needed
  • Task 7: Frontend — Conditional action buttons on Declarations pages (AC: #10)

    • 7.1 In declarations/Index.vue: hide "New Declaration" button when canCreate is false
    • 7.2 In declarations/Index.vue: hide edit/delete action links per row when canEdit/canDelete are false
    • 7.3 In declarations/Show.vue: hide edit button and mention capability when Worker
  • 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.workspaceRole is 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) 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:

$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 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.