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)) + }) +}) 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