Skip to content
Draft
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
42 changes: 41 additions & 1 deletion components/backend/cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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("")

Expand Down Expand Up @@ -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))
Expand Down
88 changes: 74 additions & 14 deletions components/backend/internal/middleware/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -360,7 +420,7 @@ func (e *Enforcer) AddPolicy(
) error {
var actualRole string
if role == nil {
actualRole = "*"
actualRole = anySubject
} else {
actualRole = role.String()
}
Expand All @@ -378,7 +438,7 @@ func (e *Enforcer) RemovePolicy(
) error {
var actualRole string
if role == nil {
actualRole = "*"
actualRole = anySubject
} else {
actualRole = role.String()
}
Expand Down
53 changes: 53 additions & 0 deletions components/backend/internal/middleware/rbac_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script lang="ts">
import { resolve } from '$app/paths';

let {
hackathonId,
pages,
current,
}: {
hackathonId: string;
/** Public pages, already ordered by the backend. */
pages: { id: string; title: string }[];
/** `'overview'`, or the id of the page being rendered. */
current: string;
} = $props();
</script>

<!--
The way around a public hackathon: its landing page, then whatever information
pages the organisers published — Schedule, Rules, FAQ, Venue.

A strip rather than a sidebar, because the (public) shell deliberately has none
(`showNav={false}` in (public)/+layout.svelte) and the member rail's entries all
point into /my/, which is exactly where a visitor cannot go.

`.chip` is this theme's segmented-control vocabulary, the same one
ParticipantsManageTabs uses, and `aria-current="page"` is the accessible form
of "you are here" for a link.

Wraps rather than scrolls. A horizontally scrolling strip centred on the page
clips its own first entry at the moment it overflows, and page titles are the
organiser's prose — six of them, any length. Wrapping has no such edge.

Drawn only when there is somewhere to go: a lone "Overview" chip on a hackathon
that has published nothing is a control that does not control anything.
-->
{#if pages.length > 0}
<nav
class="flex flex-wrap justify-center gap-1 px-4 sm:px-10 md:px-20"
aria-label="Hackathon pages"
>
<a
href={resolve(`/hackathon/${hackathonId}`)}
aria-current={current === 'overview' ? 'page' : undefined}
class="chip no-underline {current === 'overview' ? 'chip-active' : ''}"
>
Overview
</a>
{#each pages as page (page.id)}
<a
href={resolve(`/hackathon/${hackathonId}/pages/${page.id}`)}
aria-current={current === page.id ? 'page' : undefined}
class="chip no-underline {current === page.id ? 'chip-active' : ''}"
>
{page.title}
</a>
{/each}
</nav>
{/if}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MarkdownContent from '$lib/components/forms/MarkdownContent.svelte';
import HeroSection from './HeroSection.svelte';
import JoinCta from './JoinCta.svelte';
Expand All @@ -15,6 +16,7 @@
status,
signedIn,
preview = false,
nav,
}: {
id: string;
name: string;
Expand All @@ -33,6 +35,18 @@
* enrol the organiser in their own hackathon by accident.
*/
preview?: boolean;
/**
* Navigation drawn between the hero and the description — the public
* route's tab strip.
*
* A snippet the caller supplies rather than a `pages` prop this
* component turns into links: what it *is* belongs to the route that
* knows which entry is current, and this component's job is where it
* sits. The preview passes none, for the same reason it draws no Join
* block: chrome that leads somewhere is not something an organiser
* checking their own copy should be able to click out through.
*/
nav?: Snippet;
} = $props();

// Derived here, not passed in, for the same reason the whole view is one
Expand Down Expand Up @@ -62,6 +76,10 @@
]}
/>

{#if nav}
{@render nav()}
{/if}

<div class="mx-auto w-full max-w-7xl">
<section class="px-4 py-12 sm:px-10 md:px-20">
{#if description}
Expand Down
18 changes: 18 additions & 0 deletions components/frontend/src/lib/server/grpc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ export function publicHackathonClient() {
)
}

// Unauthenticated page client, for the content pages of a *public* hackathon.
//
// The backend decides, not this client: making a hackathon public writes a
// `*, /hackathon/<id>, page, read` casbin row alongside the hackathon one
// (`AllowPublicHackathonAccess`), and without that row these calls come back
// PERMISSION_DENIED. So a private hackathon's pages stay refused even though
// the call carries no token, and pages an organizer has marked `visible: false`
// are filtered out server-side, because that filter keys off `page:write`,
// which nobody anonymous will ever hold.
//
// Used even for a signed-in visitor on the public route. They are, by
// definition, not a member — members are redirected to /my/hackathon/<id> — so
// their token would buy them nothing here, and one code path is one behaviour
// to reason about.
export function publicPageClient() {
return createClientFactory().create(PageServiceDefinition, backendChannel())
}

// Per-request authorized client bundle (created by hooks.server.ts)
export interface AuthorizedGrpc {
user: UserServiceClient
Expand Down
Loading
Loading