Skip to content

fix: separate participant and admin views - #183

Open
hanaCasey wants to merge 2 commits into
developfrom
fix/participant-admin-view-leaks
Open

fix: separate participant and admin views#183
hanaCasey wants to merge 2 commits into
developfrom
fix/participant-admin-view-leaks

Conversation

@hanaCasey

Copy link
Copy Markdown
Contributor

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. The
frontend's only job is to offer or hide a control so nobody clicks into a
guaranteed PermissionDenied. So everything below is a visibility bug — bad
UX 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:181

const isOrganizer = !myMembership || myMembership.role === HACKATHON_ROLE_OWNER

The comment above it justified the !myMembership default: HackathonService.Get
admits 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.
myMembership is matched against locals.platformUser, and hooks.server.ts:206-212
deliberately proceeds with that unset when WhoAmI answers UNAVAILABLE
("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 Get serves a plain member a page with no membership
row on it
— and the default handed them the whole organiser panel:

Open/Close voting · the ballot-rules form · New category / Create / Edit / Delete
category · the live tally · Add / Record / Edit / Remove placement · Ballots JSON,
Ballots CSV, Results JSON, Results CSV

This was the only gate in the app that failed open. Every other one compares to
HACKATHON_ROLE_OWNER and so returns false for an unknown membership — which is
why 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 role
lookup that errors is read as 'not an organizer'."

Fix: mayManageVoting in capabilities.ts, following that file's convention of
citing the backend line each gate mirrors (hackathon:write, enforced by
CreateVoteCategory vote_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 (isGlobalAdmin
from 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-126

TeamCard drew an aria-label="Edit team" pencil on every row where isOwn was
true, and the participant-facing /teams list is the only place that card is mounted.

The button had 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 (/teams/manage), which is behind a route guard. So the single
control that list offered a participant was an organiser affordance that could not
have worked.

Fix: the pencil is gone. isOwn now 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.ts asserts the property over every exported gate, discovered
    from 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. mayPreferProjects is excluded by name
    and is the positive control that keeps the sweep from being a list that happens to
    hold.
  • TeamCard.test.ts pairs the two absence assertions with a positive control on the
    badge, so they cannot go green by the whole prop stopping short of the DOM.

Verification — what was actually run

Run in this environment:

Check Result
svelte-check --threshold error (with generated gRPC types present) 0 errors
vitest run (full frontend suite) 518 passed / 31 files (was 488 / 29 before this PR)
eslint on all five changed files clean
prettier --check against tools/configs/prettier clean
Both new tests red under a deliberate revert of the fix confirmed

NOT 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-28voting/+page.server.ts:181) and finding 2 is a
control with no handler, which is readable from the file. Worth a manual pass before
merge if someone has a stack up; smoke/journey reference neither isOwn nor
"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-checked
against rbac.go and the relevant handler.

Hard route guards (error(403, …) in load, 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.ts endpoints (teams/manage/template/[format], projects/proposals/export)
guard too. manage/users, manage/pages, manage/gallery guard by translating the
backend's own denial.

Shared route, internal branch — correct: participants (data.mayManage, and
mayPromote/mayDemote narrower still), timeline (data.mayManage on all four
controls), voting (organiser block 75{:else} 552{/if} 588; members get
the 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 (manageNav returns [] for
a non-owner), DashboardView (platformNav returns [] for a non-admin).

No admin control to leak: submissions (every control gated on isMine — team
membership, 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 isAdmin in the hackathon subtree resolves to GLOBAL_ROLE_ADMIN (19 sites —
one of them reading HACKATHON_ORGANIZER instead would have handed every platform
organiser the manage controls in every event); every link into an organiser route
from a participant-visible surface is gated (2 sites, both data.mayManage); and
hackathon.pages — which hackathon.get returns including visible: false — is
read only inside organiser-guarded routes, with the sidebar and the photos/webinars
tabs going to PageService.List instead, 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 from
anything but a literal
, and exactly one const x = data.… outside a $derived
(+layout.svelte:55), which sits inside a $derived IIFE and drives a badge.
Every permission flag is read as data.x in 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.Get returns hidden pages, and
every 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

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant