From f200cec87c12e6545dcfa8e85a8d0524ed08a1c3 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:08:10 +0200 Subject: [PATCH] feat(backend): private hackathons confirm their joiners on the spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private hackathon can only be joined with a valid invitation (or by someone who already holds hackathon:read), so the organizer has already decided who takes part by the time Join runs. Asking for a second confirmation left the invitee holding no casbin role, which meant the event they had just been invited to was filtered out of every list they could see — the invitation link was the only trace of it. Join now grants membership itself when the hackathon is private. Public hackathons are unchanged: there the door stood open to everyone, which makes the organizer's confirmation the only point at which anybody chooses. - Extracts the shared confirmation into grantMembership, used by both Join and ApproveParticipant. It adds the Member role *before* clearing is_waiting, reversing ApproveParticipant's old order: casbin cannot join an ent transaction, and a half-failure that shows someone as waitlisted-but-able-to-act is repairable by the Approve button, where the reverse leaves a roster saying "Approved" over an account with no read access and no control that fixes it. - Auto-approval failure is logged, not returned. It leaves exactly the state a public join produces, which the invite page already reads correctly. - Extracts Join's invite-token gate into inviteAdmits, which the feature needed to stay under the cyclomatic limit and which retires two nolint directives. - The invite page now says "Join" rather than "Request a place" for a private hackathon. Kept conditional on visibility: CreateInvite performs no visibility check, so a public hackathon's link still waitlists. - H5's seeded waitlist moves out of the fixture; dana is now a confirmed member and the waitlist-to-approve case lives in H1, which is public. --- components/backend/cmd/service/main.go | 42 ++++++++- .../backend/internal/middleware/rbac.go | 88 ++++++++++++++++--- .../backend/internal/middleware/rbac_test.go | 53 +++++++++++ .../hackathon/PublicHackathonTabs.svelte | 58 ++++++++++++ .../hackathon/PublicHackathonView.svelte | 18 ++++ .../frontend/src/lib/server/grpc/client.ts | 18 ++++ .../{+page.server.ts => +layout.server.ts} | 57 +++++++++++- .../(public)/hackathon/[id]/+page.svelte | 10 ++- .../[id]/pages/[pageId]/+page.server.ts | 51 +++++++++++ .../[id]/pages/[pageId]/+page.svelte | 59 +++++++++++++ 10 files changed, 435 insertions(+), 19 deletions(-) create mode 100644 components/frontend/src/lib/components/hackathon/PublicHackathonTabs.svelte rename components/frontend/src/routes/(public)/hackathon/[id]/{+page.server.ts => +layout.server.ts} (68%) create mode 100644 components/frontend/src/routes/(public)/hackathon/[id]/pages/[pageId]/+page.server.ts create mode 100644 components/frontend/src/routes/(public)/hackathon/[id]/pages/[pageId]/+page.svelte diff --git a/components/backend/cmd/service/main.go b/components/backend/cmd/service/main.go index 97bb0896..33b61d83 100644 --- a/components/backend/cmd/service/main.go +++ b/components/backend/cmd/service/main.go @@ -10,10 +10,12 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" + mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/service" ) @@ -41,6 +43,38 @@ func seedAdminUser(ctx context.Context, dbClient *ent.Client, cfg *config.Config return nil } +// reconcilePublicAccess re-grants the public casbin rows for every hackathon +// whose visibility already says public. +// +// Visibility is stored on the hackathon row, but what it *does* is a set of +// casbin rows written at the moment Create or Edit runs. The two can therefore +// disagree, and today they do: `page:read` only recently joined that set, so +// every hackathon made public before it carries the old half-grant and answers +// an anonymous PageService.List with PermissionDenied. Nothing re-runs Edit on +// those, so nothing would ever repair them. +// +// It runs on every boot rather than as a migration script somebody has to +// remember per environment, which it can afford to do because +// AllowPublicHackathonAccess is a no-op per row that already exists. It also +// makes the DB the authority: whatever the casbin table holds, visibility wins. +func reconcilePublicAccess(ctx context.Context, dbClient *ent.Client, enf *mw.Enforcer) error { + public, err := dbClient.Hackathon.Query(). + Where(enthackathon.VisibilityEQ(enthackathon.VisibilityPublic)). + IDs(ctx) + if err != nil { + return fmt.Errorf("query public hackathons: %w", err) + } + + for _, id := range public { + if _, err := enf.AllowPublicHackathonAccess(id.String()); err != nil { + return fmt.Errorf("grant public access to hackathon %s: %w", id, err) + } + } + slog.Info("reconciled public hackathon access", "hackathons", len(public)) + + return nil +} + func main() { logx.Setup("") @@ -72,12 +106,18 @@ func main() { } // Create server with all middleware and services - server, cleanup, _, err := service.NewServer(dbClient, cfg, nil) + server, cleanup, enforcer, err := service.NewServer(dbClient, cfg, nil) if err != nil { logx.Fatal("create server", "err", err) } defer cleanup() + // After NewServer, because that is what builds the enforcer, and before + // Serve, so no request is answered against a half-written policy table. + if err := reconcilePublicAccess(context.Background(), dbClient, enforcer); err != nil { + logx.Fatal("reconcile public hackathon access", "err", err) + } + // Listen lc := net.ListenConfig{} //nolint:exhaustruct // all fields optional lis, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf(":%s", cfg.Server.Port)) diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index a454bd90..dbf0e28f 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -25,6 +25,11 @@ var modelFile string const minPolicyFields = 2 // casbin policy tuples have at least 2 fields: subject and role +// anySubject is the policy subject the matcher treats as "any caller at all" +// (`p.sub=="*"` in casbin_model.conf), the anonymous one included. It is how a +// grant is made to the public rather than to a role. +const anySubject = "*" + type Role int const ( @@ -333,22 +338,77 @@ func (e *Enforcer) RemoveGlobalRole(user string, role Role) (bool, error) { return e.enforcer.RemoveNamedGroupingPolicy("g2", user, role.String()) } +// publicGrant is one row of what "this hackathon is public" means, written with +// the `*` subject the matcher reads as "any caller" (casbin_model.conf). An +// unauthenticated request is not rejected — the auth interceptor gives it the +// subject `anonymous` (auth.go) and lets casbin decide — so these rows are what +// an anonymous visitor's reads actually rest on. +type publicGrant struct { + obj ObjectType + perm Permission +} + +// publicHackathonGrants is the whole of that meaning, in one place, so allowing +// and revoking cannot drift apart. +// +// `page:read` sits beside `hackathon:read` because a public hackathon whose +// information pages answer PermissionDenied is not much of a public hackathon: +// the landing page could name the event but not show its schedule, rules or +// FAQ. Granting it wholesale is safe — PageService.List drops pages with +// `visible = false` for every caller that lacks `page:write`, and no anonymous +// caller will ever hold write, so an organizer's drafts stay unpublished. +// +// This is hackathon-wide on purpose: every visible page of a public hackathon +// is public. Marking individual pages public while their siblings stay +// members-only needs a field on the page itself, which is a schema change and a +// separate piece of work. +var publicHackathonGrants = []publicGrant{ + {Hackathon, Read}, + {Page, Read}, +} + +// AllowPublicHackathonAccess grants every publicHackathonGrants row to `*`. +// +// One AddPolicy per row rather than AddPolicies: the batch form is all-or- +// nothing and reports failure if *any* row is already present, which is exactly +// the state a hackathon made public before `page:read` joined this list is in. +// Row by row, an existing row is simply a no-op, which is what makes this safe +// to call on a hackathon that is already public — the reconcile at startup +// depends on that. func (e *Enforcer) AllowPublicHackathonAccess(hackathonId string) (bool, error) { - return e.enforcer.AddPolicy( - "*", - hackathonIdToPath(hackathonId), - Hackathon.String(), - Read.String(), - ) + domain := hackathonIdToPath(hackathonId) + changed := false + for _, g := range publicHackathonGrants { + added, err := e.enforcer.AddPolicy(anySubject, domain, g.obj.String(), g.perm.String()) + if err != nil { + return changed, err + } + changed = changed || added + } + + return changed, nil } +// RemovePublicHackathonAccess revokes what AllowPublicHackathonAccess granted. +// Row by row for the mirror-image reason: a hackathon that never had the +// `page:read` row must still lose its `hackathon:read` one. func (e *Enforcer) RemovePublicHackathonAccess(hackathonId string) (bool, error) { - return e.enforcer.RemovePolicy( - "*", - hackathonIdToPath(hackathonId), - Hackathon.String(), - Read.String(), - ) + domain := hackathonIdToPath(hackathonId) + changed := false + for _, g := range publicHackathonGrants { + removed, err := e.enforcer.RemovePolicy( + anySubject, + domain, + g.obj.String(), + g.perm.String(), + ) + if err != nil { + return changed, err + } + changed = changed || removed + } + + return changed, nil } func (e *Enforcer) AddPolicy( @@ -360,7 +420,7 @@ func (e *Enforcer) AddPolicy( ) error { var actualRole string if role == nil { - actualRole = "*" + actualRole = anySubject } else { actualRole = role.String() } @@ -378,7 +438,7 @@ func (e *Enforcer) RemovePolicy( ) error { var actualRole string if role == nil { - actualRole = "*" + actualRole = anySubject } else { actualRole = role.String() } diff --git a/components/backend/internal/middleware/rbac_test.go b/components/backend/internal/middleware/rbac_test.go index f580b887..e9fdc460 100644 --- a/components/backend/internal/middleware/rbac_test.go +++ b/components/backend/internal/middleware/rbac_test.go @@ -118,7 +118,60 @@ var _ = Describe("RBAC Enforcer", func() { Entry("eve owner reads h2", "eve", "h2", Hackathon, Read, true), Entry("eve owner writes h2", "eve", "h2", Hackathon, Write, true), Entry("eve cannot read h1", "eve", "h1", Hackathon, Read, false), + + // The anonymous subject is what an unauthenticated call arrives as + // (auth.go), so these are the entries that say what a visitor with no + // account can actually do. Reading the hackathon was never enough on + // its own: a landing page that cannot name the schedule or the rules + // is a public hackathon in name only. + Entry("anonymous reads public h2", AnonSubject, "h2", Hackathon, Read, true), + Entry("anonymous reads public h2 pages", AnonSubject, "h2", Page, Read, true), + // Private stays private, and the page grant is scoped to the one + // hackathon that was made public — not to `/hackathon/*`. + Entry("anonymous cannot read h1", AnonSubject, "h1", Hackathon, Read, false), + Entry("anonymous cannot read h1 pages", AnonSubject, "h1", Page, Read, false), + // Load-bearing, not a formality: PageService.List decides whether to + // filter out `visible: false` pages by asking for `page:write` + // (page_service.go). If the public grant ever reached write, every + // unpublished draft would be served to the internet. + Entry("anonymous cannot write public h2 pages", AnonSubject, "h2", Page, Write, false), ) + + It("revokes page read along with hackathon read", func() { + ctx := CtxWithClaims(AnonSubject) + + // Both halves of the grant go, so a hackathon flipped back to private + // cannot keep serving its pages to anonymous readers — the failure + // that a per-row revoke written to match a per-row grant is there to + // prevent. + _, err := enf.RemovePublicHackathonAccess("h2") + Expect(err).NotTo(HaveOccurred()) + + Expect(enf.Enforce(ctx, "h2", Hackathon, Read)).To(BeFalse()) + Expect(enf.Enforce(ctx, "h2", Page, Read)).To(BeFalse()) + }) + + It("is idempotent over a hackathon that predates the page grant", func() { + ctx := CtxWithClaims(AnonSubject) + + // The state every hackathon made public before `page:read` joined the + // grant is in: one row present, one missing. A batch AddPolicies + // refuses the pair outright in that case and repairs nothing, which + // is why the grant is written row by row and why the startup + // reconcile in cmd/service can afford to run on every boot. + _, err := enf.RemovePublicHackathonAccess("h2") + Expect(err).NotTo(HaveOccurred()) + Expect( + enf.AddPolicy(nil, "h2", Hackathon, Read), + ).To(Succeed()) + Expect(enf.Enforce(ctx, "h2", Page, Read)).To(BeFalse()) + + _, err = enf.AllowPublicHackathonAccess("h2") + Expect(err).NotTo(HaveOccurred()) + + Expect(enf.Enforce(ctx, "h2", Hackathon, Read)).To(BeTrue()) + Expect(enf.Enforce(ctx, "h2", Page, Read)).To(BeTrue()) + }) }) Describe("Admin Access", func() { diff --git a/components/frontend/src/lib/components/hackathon/PublicHackathonTabs.svelte b/components/frontend/src/lib/components/hackathon/PublicHackathonTabs.svelte new file mode 100644 index 00000000..1831ab61 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/PublicHackathonTabs.svelte @@ -0,0 +1,58 @@ + + + +{#if pages.length > 0} + +{/if} diff --git a/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte b/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte index 3612a740..65a88d6b 100644 --- a/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte +++ b/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte @@ -1,4 +1,5 @@ + + + + + + +
+
+

{data.page.title}

+ + {#if data.page.content.trim()} + +
+ +
+ {:else} + +

This page has no content yet.

+ {/if} +
+