Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions components/backend/internal/service/hackathon_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions components/backend/internal/service/registration_gate_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
16 changes: 16 additions & 0 deletions components/backend/internal/service/storage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
Loading