From 5bb39da5982ce4fa18af20caf60838d4a4d36cde Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 16:31:54 -0300 Subject: [PATCH] fix(permissions): enforce workspace roles across backend and UI Viewers could mutate posts, automations and trigger AI write endpoints, and every role saw create/manage affordances that 403'd on click. Backend (security): - PostPolicy update/delete now require member+ (was tenancy-only), which also gates the AI write endpoints that authorize('update') - AutomationPolicy create/update/delete require member+; activate/pause delegate to update - AutomationController authorizes index/store/show; AnalyticsController authorizes view - Comments stay open to members incl. viewer (by design) Frontend (UI gating via new useWorkspaceRole composable): - Sidebar: create post / create workspace / automations / library nav - Accounts grid: connect / disconnect / reconnect (admin+) - Members: invite / change role / remove / cancel invite (admin+) - Account billing tab (owner); posts index + calendar create affordances Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an end-to-end WorkspaceRolePermissionsTest; aligned the automation test suites' account/workspace setup with role pivots. --- .../Controllers/App/AnalyticsController.php | 2 + .../Controllers/App/AutomationController.php | 6 ++ app/Policies/AutomationPolicy.php | 9 ++- app/Policies/PostPolicy.php | 7 +- resources/js/components/AppSidebar.vue | 68 +++++++++++-------- .../accounts/NetworkConnectGrid.vue | 55 ++++++++------- resources/js/components/settings/UsersTab.vue | 8 ++- resources/js/composables/useWorkspaceRole.ts | 42 ++++++++++++ resources/js/pages/posts/Calendar.vue | 7 +- resources/js/pages/posts/Index.vue | 9 ++- .../js/pages/settings/account/Account.vue | 7 +- .../Feature/Automation/AutomationCrudTest.php | 7 +- tests/Feature/Automation/DetailTabsTest.php | 5 +- tests/Feature/Automation/DryRunTest.php | 5 +- .../Feature/Automation/FeedInspectionTest.php | 5 +- .../Automation/GenerateNodeValidationTest.php | 7 +- tests/Feature/Automation/VariablesTest.php | 5 +- .../Automation/WebhookNodeValidationTest.php | 7 +- .../WorkspaceRolePermissionsTest.php | 62 +++++++++++++++++ tests/Unit/Policies/AutomationPolicyTest.php | 60 ++++++++++++++++ tests/Unit/Policies/PostPolicyTest.php | 56 +++++++++++++++ 21 files changed, 354 insertions(+), 85 deletions(-) create mode 100644 resources/js/composables/useWorkspaceRole.ts create mode 100644 tests/Feature/Permissions/WorkspaceRolePermissionsTest.php create mode 100644 tests/Unit/Policies/AutomationPolicyTest.php create mode 100644 tests/Unit/Policies/PostPolicyTest.php diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index aee64aad..919486bf 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -42,6 +42,8 @@ public function index(Request $request): Response { $workspace = $request->user()->currentWorkspace; + $this->authorize('view', $workspace); + $accounts = $workspace->socialAccounts() ->where('is_active', true) ->whereIn('platform', self::SUPPORTED_PLATFORMS) diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index e691eb8c..474cc140 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -48,6 +48,8 @@ class AutomationController extends Controller { public function index(ListAutomations $list): Response { + $this->authorize('viewAny', Automation::class); + $workspace = request()->user()->currentWorkspace; $automations = Inertia::scroll(fn () => AutomationResource::collection( @@ -61,6 +63,8 @@ public function index(ListAutomations $list): Response public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse { + $this->authorize('create', Automation::class); + $automation = $create( $request->user()->currentWorkspace, $request->user(), @@ -71,6 +75,8 @@ public function store(StoreAutomationRequest $request, CreateAutomation $create) public function show(Automation $automation): RedirectResponse { + $this->authorize('view', $automation); + return redirect()->route('app.automations.workflow', $automation->id); } diff --git a/app/Policies/AutomationPolicy.php b/app/Policies/AutomationPolicy.php index 736147e8..5c1e5bdc 100644 --- a/app/Policies/AutomationPolicy.php +++ b/app/Policies/AutomationPolicy.php @@ -21,17 +21,20 @@ public function view(User $user, Automation $automation): bool public function create(User $user): bool { - return $user->currentWorkspace !== null; + return $user->currentWorkspace !== null + && $user->can('createPost', $user->currentWorkspace); } public function update(User $user, Automation $automation): bool { - return $automation->workspace_id === $user->current_workspace_id; + return $automation->workspace_id === $user->current_workspace_id + && $user->can('createPost', $user->currentWorkspace); } public function delete(User $user, Automation $automation): bool { - return $automation->workspace_id === $user->current_workspace_id; + return $automation->workspace_id === $user->current_workspace_id + && $user->can('createPost', $user->currentWorkspace); } public function activate(User $user, Automation $automation): bool diff --git a/app/Policies/PostPolicy.php b/app/Policies/PostPolicy.php index c89f8267..e542176b 100644 --- a/app/Policies/PostPolicy.php +++ b/app/Policies/PostPolicy.php @@ -33,11 +33,12 @@ public function update(User $user, Post $post): bool|Response return Response::denyAsNotFound(); } - return true; + return $user->can('createPost', $user->currentWorkspace); } /** - * Authorize deleting a post. Same workspace-tenancy guard as `view`. + * Authorize deleting a post: tenancy guard (404 across tenants) then the + * same role gate as `update` — viewers are read-only (403). */ public function delete(User $user, Post $post): bool|Response { @@ -45,7 +46,7 @@ public function delete(User $user, Post $post): bool|Response return Response::denyAsNotFound(); } - return true; + return $user->can('createPost', $user->currentWorkspace); } /** diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f472b314..971cdc73 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -43,6 +43,7 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { useActiveUrl } from '@/composables/useActiveUrl'; +import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app'; import { index as assets } from '@/routes/app/assets'; import { index as automations } from '@/routes/app/automations'; @@ -63,6 +64,8 @@ const currentWorkspace = computed(() => page.props.auth.curren const workspaces = computed(() => page.props.auth.workspaces as Workspace[]); const subscriptionPastDue = computed(() => Boolean(page.props.auth.subscriptionPastDue)); +const { canCreatePost, canManageAutomations, canCreateWorkspace } = useWorkspaceRole(); + const mainNavItems = computed(() => [ { title: trans('sidebar.posts.calendar'), @@ -74,12 +77,16 @@ const mainNavItems = computed(() => [ href: analytics.url(), icon: IconChartBar, }, - { - title: trans('sidebar.automations'), - href: automations.url(), - icon: IconBolt, - badge: 'Beta', - }, + ...(canManageAutomations.value + ? [ + { + title: trans('sidebar.automations'), + href: automations.url(), + icon: IconBolt, + badge: 'Beta', + }, + ] + : []), ]); const postsNavItems = computed(() => [ @@ -112,21 +119,26 @@ const workspaceNavItems = computed(() => [ href: accounts.url(), icon: IconAffiliate, }, - { - title: trans('sidebar.workspace.signatures'), - href: signatures.url(), - icon: IconHash, - }, - { - title: trans('sidebar.workspace.labels'), - href: labels.url(), - icon: IconTag, - }, - { - title: trans('sidebar.workspace.assets'), - href: assets.url(), - icon: IconPhoto, - }, + // Signatures / labels / assets are content-authoring tools (member+ only). + ...(canCreatePost.value + ? [ + { + title: trans('sidebar.workspace.signatures'), + href: signatures.url(), + icon: IconHash, + }, + { + title: trans('sidebar.workspace.labels'), + href: labels.url(), + icon: IconTag, + }, + { + title: trans('sidebar.workspace.assets'), + href: assets.url(), + icon: IconPhoto, + }, + ] + : []), ]); const switchWorkspace = (workspaceId: string) => { @@ -176,11 +188,13 @@ const handleCreateWorkspace = () => { {{ workspace.name }} - - - - {{ $t('sidebar.create_workspace') }} - + @@ -189,7 +203,7 @@ const handleCreateWorkspace = () => { -
+
- - - +
diff --git a/resources/js/components/settings/UsersTab.vue b/resources/js/components/settings/UsersTab.vue index 5463726e..f1d7f385 100644 --- a/resources/js/components/settings/UsersTab.vue +++ b/resources/js/components/settings/UsersTab.vue @@ -22,6 +22,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import { destroy as destroyInvite } from '@/routes/app/invites'; import { remove as removeMemberRoute, updateRole } from '@/routes/app/members'; import { WorkspaceRole } from '@/types/workspace-role'; @@ -65,6 +66,8 @@ const roleIcon = (role: string) => { const page = usePage(); const currentUserId = computed(() => page.props.auth.user.id); +const { canManageTeam } = useWorkspaceRole(); + const inviteDialogOpen = ref(false); const removeMemberModal = ref | null>(null); const cancelInvitationModal = ref | null>(null); @@ -86,7 +89,7 @@ const changeRole = (member: Member, role: string) => { :description="$t('settings.workspace.members_description')" /> - @@ -110,7 +113,7 @@ const changeRole = (member: Member, role: string) => { - + @@ -374,6 +377,7 @@ const formatTime = (scheduledAt: string): string => {
@@ -471,6 +475,7 @@ const formatTime = (scheduledAt: string): string => { {{ day.format('D') }} diff --git a/resources/js/pages/posts/Index.vue b/resources/js/pages/posts/Index.vue index f99ac13a..ab7ec15e 100644 --- a/resources/js/pages/posts/Index.vue +++ b/resources/js/pages/posts/Index.vue @@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { useWorkspaceEcho } from '@/composables/echo/useWorkspaceEcho'; import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; import { getPostStatusConfig } from '@/composables/usePostStatus'; +import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import dayjs from '@/dayjs'; import debounce from '@/debounce'; import AppLayout from '@/layouts/AppLayout.vue'; @@ -147,6 +148,8 @@ const postUrl = (post: Post): string => const deleteModal = ref | null>(null); +const { canCreatePost } = useWorkspaceRole(); + const handleDelete = (post: Post) => { deleteModal.value?.open({ url: destroyPost.url(post.id), @@ -194,7 +197,7 @@ useWorkspaceEcho(
- + @@ -297,7 +300,7 @@ useWorkspaceEcho(
- + {{ $t('posts.actions.duplicate') }} @@ -305,7 +308,7 @@ useWorkspaceEcho( {{ $t('posts.actions.copy_id') }} -