From f5cb371023567ee5af512b75750ed0a684fa1969 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 11:26:14 +0200 Subject: [PATCH] feat(broadcasting): extend submission.{id} channel auth to organisation admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN. WS-6 v1.3-delta D2 (PR #11 23a5696) introduced submission.{id} private channel with submitter-only authorization, deferring org-admin auth to a follow-up after the Spatie Permission helper convention was audited. This commit closes that follow-up. Authorization now permits (cheap-first short-circuit): 1. Submitter (submitted_by_user_id === user.id) — unchanged 2. super_admin (Spatie HasRoles app-wide bypass) — audit-surfaced bonus, matches every analogous policy in the codebase 3. Organisation admins of the submission's organisation — new Pattern: direct port of FormSubmissionActionFailurePolicy::canAccess. Spatie teams is disabled in config/permission.php, so org-scoping lives in the user_organisation pivot table's `role` column with wherePivot('role', 'org_admin') — codebase canonical (used in 17+ policy sites). withoutGlobalScopes() preserved on both FormSubmission and Organisation lookups so channel auth is a structural gate, not a tenant-scoped query. Inline TODO removed; the BACKLOG entry transitions to resolved in a follow-up commit on this branch. Co-Authored-By: Claude Opus 4.7 --- api/routes/channels.php | 52 ++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/api/routes/channels.php b/api/routes/channels.php index b6ec7bc2..c5a62a10 100644 --- a/api/routes/channels.php +++ b/api/routes/channels.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\FormBuilder\FormSubmission; +use App\Models\Organisation; use App\Models\User; use Illuminate\Support\Facades\Broadcast; @@ -30,11 +31,19 @@ use Illuminate\Support\Facades\Broadcast; * IdentityMatchBanner subscribes via Echo.private('submission.{id}') and * refetches the submission resource on receipt. * - * v1 authz scope: only the submitter who created the submission via an - * authenticated session is allowed to subscribe. Org-admin access is - * deferred — see BACKLOG entry TECH-CHANNEL-AUTH-ORG-ADMIN. Public - * (token-based) submitters are not on this channel; their flow is - * already polling-based and they don't have a User to authenticate with. + * Authorisation paths (in order of cheap-first short-circuit): + * 1. Submitter (submitted_by_user_id === user.id) — common case for the + * portal banner; no DB lookup beyond the submission itself. + * 2. super_admin (Spatie HasRoles, app-wide bypass) — debugging, + * impersonation, platform-level support. + * 3. Organisation admin of the submission's organisation — pivot-table + * check on user_organisation with role='org_admin'. Codebase + * canonical pattern, mirroring FormSubmissionActionFailurePolicy::canAccess + * (Spatie teams is disabled in config/permission.php; org-scoping + * lives in the pivot, not in Spatie). + * + * Public (token-based) submitters are not on this channel; their flow is + * polling-based and they don't have a User to authenticate with. */ Broadcast::channel( 'submission.{submissionId}', @@ -47,11 +56,32 @@ Broadcast::channel( return false; } - // TODO TECH-CHANNEL-AUTH-ORG-ADMIN — extend to organisation admins - // once we audit the Spatie Permission helper for an - // organisation-scoped role check (hasRoleInOrganisation or - // similar). Until that audit lands, only the submitter has - // channel access. - return $submission->submitted_by_user_id === $user->id; + // Submitter has access (authenticated session at submit time). + if ($submission->submitted_by_user_id === $user->id) { + return true; + } + + // super_admin app-wide bypass (Spatie HasRoles, global role). + if ($user->hasRole('super_admin')) { + return true; + } + + // Org admins of the submission's organisation. Pivot-table check + // matching the codebase's canonical pattern (see e.g. + // FormSubmissionActionFailurePolicy::canAccess). Spatie teams is + // disabled in config/permission.php, so org-scoping lives in the + // user_organisation pivot's `role` column, not Spatie. + $organisation = Organisation::query() + ->withoutGlobalScopes() + ->find($submission->organisation_id); + + if (! $organisation instanceof Organisation) { + return false; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); }, );