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 @@
+
+
+
This page has no content yet.
+ {/if} +