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(); }, );