fix: separate participant and admin views - #183
Open
hanaCasey wants to merge 2 commits into
Open
Conversation
The voting page decided `isOrganizer` as `!myMembership || myMembership.role === OWNER`, on the reasoning that `HackathonService.Get` admits only confirmed participants, owners and global admins — so a viewer with no membership row must be an admin looking in. That premise is the BACKEND's, and this is not the backend's view. `myMembership` is matched against `locals.platformUser`, which `hooks.server.ts` deliberately leaves unset when `WhoAmI` answers `UNAVAILABLE` (it logs "proceeding without platform user" and carries on). The gRPC channel's reconnect backoff is capped at 2s, so a backend that has come back by the time the layout issues its own `Get` serves a plain member a page with no membership row on it — and the default then handed them the entire organiser panel: open/close voting, the ballot rules, category create/edit/delete, the tally, placements, and the four ballot and result exports. Every other gate in the app fails closed by comparing to HACKATHON_ROLE_OWNER, and so does the backend's own `VoteService.isOrganizer`, whose comment says a role lookup that errors is read as "not an organizer". This one was the outlier. `mayManageVoting` joins the other gates in capabilities.ts, citing the `hackathon:write` enforcement behind each RPC in that panel, and the admin escape hatch is now stated rather than inferred from an absence. The test asserts the PROPERTY over every exported gate rather than over the one that was wrong, so a helper added later cannot reintroduce the same default silently; `mayPreferProjects` is excluded by name and is the positive control that keeps the sweep from being a list that happens to hold. Claude-Session: https://claude.ai/code/session_01HM1BPLRwr3yVMoPuqcXHJN
TeamCard drew an "Edit team" pencil on every row the viewer was a member of, and the participant-facing Teams list is the only place that card is mounted. The button carried no handler of any kind — no `onclick`, no `href`, no form — because there is no participant-side team editor: teams are edited from the organiser's Manage Teams board, which sits behind a route guard. So the one control that list offered a participant was an organiser affordance that could not have worked, which is exactly what #182 describes. `isOwn` now draws the fact it actually carries — which row is yours — worded the way the submissions page already words it, rather than being deleted along with the button and leaving a prop nothing reads. The two absence assertions in the test are paired with a positive control on the badge, so they cannot go green by the whole prop stopping at the DOM. Claude-Session: https://claude.ai/code/session_01HM1BPLRwr3yVMoPuqcXHJN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #182
What this is
An audit of every frontend surface a plain hackathon Member (or an anonymous
visitor) can reach, looking for controls that belong to the organiser/admin view,
plus fixes for what it found.
Framing, so the severity is not misread: casbin lives entirely in the backend
and every mutation goes through
enforcer.Enforce/RequirePermission. Thefrontend's only job is to offer or hide a control so nobody clicks into a
guaranteed
PermissionDenied. So everything below is a visibility bug — badUX and a trust problem — not an auth bypass. The backend refused these actions
before this PR and refuses them after it.
Findings that are fixed
1. The voting page read "membership unknown" as "organiser" —
voting/+page.server.ts:181The comment above it justified the
!myMembershipdefault:HackathonService.Getadmits only confirmed participants, hackathon owners and global admins, so a viewer
who reached the page with no membership row must be an admin looking in.
That premise is the backend's view, and this is not the backend's view.
myMembershipis matched againstlocals.platformUser, andhooks.server.ts:206-212deliberately proceeds with that unset when
WhoAmIanswersUNAVAILABLE("Backend unavailable for WhoAmI, proceeding without platform user"). The gRPC
channel's reconnect backoff is capped at 2s, so a backend that has come back by the
time the layout issues its own
Getserves a plain member a page with no membershiprow on it — and the default handed them the whole organiser panel:
This was the only gate in the app that failed open. Every other one compares to
HACKATHON_ROLE_OWNERand so returnsfalsefor an unknown membership — which iswhy the rest of the app correctly degrades to the participant view in the same window,
and voting alone does not. The backend's own
VoteService.isOrganizer(
vote_service.go:791-807) fails closed too, and says so in its comment: "A rolelookup that errors is read as 'not an organizer'."
Fix:
mayManageVotingincapabilities.ts, following that file's convention ofciting the backend line each gate mirrors (
hackathon:write, enforced byCreateVoteCategoryvote_service.go:257,EditVoteCategory:315,DeleteVoteCategory:418,ExportVotes:1071,CreateVoteResult:1286,EditVoteResult:1329,DeleteVoteResult:1378,SuggestResults:1419,ExportResults:1593). The admin escape hatch is now stated (isGlobalAdminfrom the layout) rather than inferred from an absence, so an admin who never joined
keeps the panel and nobody else gains it.
2. A dead "Edit team" pencil on the participant Teams list —
TeamCard.svelte:118-126TeamCarddrew anaria-label="Edit team"pencil on every row whereisOwnwastrue, and the participant-facing
/teamslist is the only place that card is mounted.The button had no handler of any kind — no
onclick, nohref, no form — becausethere is no participant-side team editor. Teams are edited from the organiser's
Manage Teams board (
/teams/manage), which is behind a route guard. So the singlecontrol that list offered a participant was an organiser affordance that could not
have worked.
Fix: the pencil is gone.
isOwnnow renders the fact it actually carries — a"Your team" badge, worded the way the submissions page already words it — rather than
being deleted along with the button and leaving a prop nothing reads.
Regression guards
Both fixes are pinned by unit tests, and both tests were verified to go red against
the pre-fix code (mutation applied, suite run, mutation reverted) — an
absence-assertion that has never failed is not a test.
capabilities.test.tsasserts the property over every exported gate, discoveredfrom the module rather than listed by hand, so a helper added later cannot
reintroduce the same default silently: unknown membership → false, plain member →
false, owner → true, global admin → true.
mayPreferProjectsis excluded by nameand is the positive control that keeps the sweep from being a list that happens to
hold.
TeamCard.test.tspairs the two absence assertions with a positive control on thebadge, so they cannot go green by the whole prop stopping short of the DOM.
Verification — what was actually run
Run in this environment:
svelte-check --threshold error(with generated gRPC types present)vitest run(full frontend suite)eslinton all five changed filesprettier --checkagainsttools/configs/prettierNOT run, and stated plainly rather than implied: no browser walkthrough. The
stack in this workspace runs inside a devcontainer whose ports are not reachable from
where this ran, and standing up a second Postgres/Keycloak would have fought with a
concurrently running instance for :5432 and :8081. So nothing here was clicked
through as bob — finding 1 is reasoned from the code path (
hooks.server.ts:206-212→
+layout.server.ts:27-28→voting/+page.server.ts:181) and finding 2 is acontrol with no handler, which is readable from the file. Worth a manual pass before
merge if someone has a stack up;
smoke/journeyreference neitherisOwnnor"Edit team", so neither suite covers this today.Audited and found correct — recorded so it is not re-litigated
Read start to finish, every
{#if}matched to its{:else}/{/if}, cross-checkedagainst
rbac.goand the relevant handler.Hard route guards (
error(403, …)inload, so the component never renders):tracks,tracks/new,tracks/[trackId]/edit,pages,pages/new,pages/[pageId]/edit,timeline/new,timeline/[phaseId]/edit,manage(hub),manage/edit(canEditHackathon),prizes,windows,forms,invites,email,teams/manage,hackathons/create,projects/proposals/propose,projects/[projectId]/edit,projects/proposals/[projectId]/edit. The two organiser+server.tsendpoints (teams/manage/template/[format],projects/proposals/export)guard too.
manage/users,manage/pages,manage/galleryguard by translating thebackend's own denial.
Shared route, internal branch — correct:
participants(data.mayManage, andmayPromote/mayDemotenarrower still),timeline(data.mayManageon all fourcontrols),
voting(organiser block75→{:else}552→{/if}588; members getthe ballot in the else-branch),
projects(project.mayEdit,data.mayReview,data.mayPrefer— three genuinely distinct checks),projects/[projectId],projects/proposals(mayExport),overview(organiserVoice),OrganizerStateAlert(canManage),HackathonSidebar(manageNavreturns[]fora non-owner),
DashboardView(platformNavreturns[]for a non-admin).No admin control to leak:
submissions(every control gated onisMine— teammembership, which is a different axis from organiser and correctly so),
teams(list),pages/[pageId],photos,webinars,account,register/[id], all of(public)/**,ParticipantCard,ProjectCard,CurrentStateCard,ParticipationCard,TrackBreakdown,HeroCompact,PhaseTimeline,BallotCard,ResultsList,ExportPanel,NavBar,AppSidebar.Consistency checks that could have found a whole class of leak and did not:
every
isAdminin the hackathon subtree resolves toGLOBAL_ROLE_ADMIN(19 sites —one of them reading
HACKATHON_ORGANIZERinstead would have handed every platformorganiser the manage controls in every event); every link into an organiser route
from a participant-visible surface is gated (2 sites, both
data.mayManage); andhackathon.pages— whichhackathon.getreturns includingvisible: false— isread only inside organiser-guarded routes, with the sidebar and the photos/webinars
tabs going to
PageService.Listinstead, which filters.The Svelte 5 staleness hypothesis is disproved, not merely unfound. A stale
permission flag captured at mount would look exactly like this bug without being a
missing check. There is no
$state()anywhere in the codebase initialised fromanything but a literal, and exactly one
const x = data.…outside a$derived(
+layout.svelte:55), which sits inside a$derivedIIFE and drives a badge.Every permission flag is read as
data.xin the template or through$derived.Deliberately not in scope
#178, #179, #180 and #181 are separate tickets and are untouched. Two things noticed
in passing that are backend disclosure questions rather than control-visibility
ones, and belong in their own issue:
HackathonService.Getreturns hidden pages, andevery project at every status, to any confirmed member — in both cases the frontend
filters, so nothing organiser-only is drawn, but the data crosses the wire.
https://claude.ai/code/session_01HM1BPLRwr3yVMoPuqcXHJN