From 5d4154457d926828c937b03095634e32962faa78 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:56:19 +0200 Subject: [PATCH 1/2] fix(backend): gate the registration-form self-path on participation (D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRegistrationTarget checked Hackathon:Write for the on_behalf_of path but returned the caller unconditionally on the self path. So any authenticated user could POST SubmitRegistrationForm for ANY hackathon — a private one they were never invited to included — which both wrote a FormResponse row into that event and turned the validation errors into a form-schema oracle (they name missing and unknown fields). Require a Participant row on the self path. Join is what writes it, and a private event's Join requires an invite, so a participant row is the proof the caller belongs in this form. Waitlisted participants pass — they are exactly who still needs to submit or correct their answers. The check runs before the form is loaded, so a non-participant is refused before any schema detail leaks. Pins it: registration_gate_test.go asserts a non-participant gets PermissionDenied and a waitlisted joiner passes the gate (reaching the form-not-defined FailedPrecondition — a different code is the proof). --- .../internal/service/hackathon_service.go | 24 +++++ .../service/registration_gate_test.go | 96 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 components/backend/internal/service/registration_gate_test.go diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 808b3eed..ff2e70e9 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -1833,6 +1833,30 @@ func (s *HackathonService) resolveRegistrationTarget( onBehalfOf *string, ) (*ent.User, error) { if onBehalfOf == nil { + // The self path still needs a gate. Without one, any authenticated caller + // could file a registration response into ANY hackathon — a private one + // they were never invited to included — and read its form schema off the + // validation errors below (which name missing and unknown fields). + // Registration follows Join, which writes the Participant row and, for a + // private event, requires an invite; a participant row is the proof this + // caller belongs in this form. Waitlisted participants pass — they are + // exactly who still needs to submit or correct their answers. + isParticipant, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.IDEQ(caller.ID)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant for registration", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !isParticipant { + return nil, status.Error(codes.PermissionDenied, + "join this hackathon before submitting its registration form") + } + return caller, nil } if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { diff --git a/components/backend/internal/service/registration_gate_test.go b/components/backend/internal/service/registration_gate_test.go new file mode 100644 index 00000000..aee43922 --- /dev/null +++ b/components/backend/internal/service/registration_gate_test.go @@ -0,0 +1,96 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// D3: SubmitRegistrationForm's self path (on_behalf_of unset) had no gate, so +// any authenticated user could file a response into any hackathon — a private +// one included — and read its form schema off the validation errors. The gate +// requires a Participant row, which Join writes (and a private event's Join +// needs an invite). Waitlisted participants must still pass. +var _ = Describe("SubmitRegistrationForm self-path gate (D3)", func() { + adminCtxFor := func(kc string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+testutils.CreateTestJWTToken(kc)), + ) + } + + It("D3 refuses a non-participant on the self path", func() { + dbClient, conn, _ := testutils.CreateTestServer() + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + + adminCtx := adminCtxFor(testutils.TestAdminKeycloakID) + now := time.Now() + h, err := hackathonClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Reg Gate", + Description: testutils.StringPtr("d"), + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + + // A user who never joined. + outsider := "d3-outsider" + _, err = dbClient.User.Create(). + SetKeycloakID(outsider).SetUsername(outsider).Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + _, err = hackathonClient.SubmitRegistrationForm(adminCtxFor(outsider), + &msgs.SubmitRegistrationFormRequest{HackathonId: h.GetHackathonId()}) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("D3 lets a joined (waitlisted) participant past the gate", func() { + dbClient, conn, _ := testutils.CreateTestServer() + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + + adminCtx := adminCtxFor(testutils.TestAdminKeycloakID) + now := time.Now() + h, err := hackathonClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Reg Gate 2", + Description: testutils.StringPtr("d"), + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: h.GetHackathonId(), + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + + member := "d3-member" + _, err = dbClient.User.Create(). + SetKeycloakID(member).SetUsername(member).Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + memberCtx := adminCtxFor(member) + _, err = hackathonClient.Join(memberCtx, &msgs.JoinRequest{HackathonId: h.GetHackathonId()}) + Expect(err).NotTo(HaveOccurred()) + + // Past the gate now: no form is defined, so the FORM check refuses it — + // FailedPrecondition, not the gate's PermissionDenied. A different code + // is the proof the participation gate let a waitlisted member through. + _, err = hackathonClient.SubmitRegistrationForm(memberCtx, + &msgs.SubmitRegistrationFormRequest{HackathonId: h.GetHackathonId()}) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + }) +}) From f23f428a2fdd7a51bc363ea66cd0258d1d804c3a Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:56:20 +0200 Subject: [PATCH 2/2] fix(backend): bind submission-attachment uploads to the submissions window (D6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SUBMISSION_ATTACHMENT presign path checked casbin Submission:Write but not the submissions window or the create_project_submissions capability — the two gates every other submission write goes through (CreateSubmission, EditSubmission, FinalizeSubmission in team_service.go). So a team could presign and upload an attachment after the deadline closed, or while the submit capability was off, slipping work in past the window the submission handlers enforce. Add the same requireWindowOpen(windowSubmissions) + requireCapability( CreateProjectSubmissions) calls the submission handlers use, on the same hackathon id already resolved from the submission's team. --- .../backend/internal/service/storage_service.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/backend/internal/service/storage_service.go b/components/backend/internal/service/storage_service.go index 408df716..356dec6a 100644 --- a/components/backend/internal/service/storage_service.go +++ b/components/backend/internal/service/storage_service.go @@ -14,6 +14,7 @@ import ( enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" storagepb "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" @@ -372,6 +373,21 @@ func (s *StorageService) authorizeUpload( ); err != nil { return "", err } + // Presigning an attachment IS a submission write, so it is bound by the + // same clock and switch CreateSubmission is (team_service.go). Without + // these, an attachment could be uploaded after the submissions window + // closes, or while the submit capability is off — slipping work in past + // the deadline every other submission path enforces. + if err := requireWindowOpen( + ctx, s.dbClient, hackathonID, windowSubmissions, time.Now(), + ); err != nil { + return "", err + } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.CreateProjectSubmissions, + ); err != nil { + return "", err + } return teamPrefix + team.ID.String() + "/submissions/" + subm.ID.String() + "/" + name, nil