ADR-0010: Public data is served by an unauthenticated API surface, controlled by field set
| Status |
Accepted |
| Date |
2026-08-16 |
| Deciders |
Christhonie Geldenhuys (architect) |
| Related |
Epic #34; Feature #957 (public participant list); Feature #963 (event visibility flag); Public Participant List; ADR-0009 |
1. Context
Event registrants want to confirm their entry landed and see who else is entered; organisers want the same list broken down by category and by school. Until now this was met by running a SQL query by hand, pasting the result into a spreadsheet, adding pivot tables, and emailing it.
Serving that list from the registration portal raised a question the system had not had to answer directly: what does "public" mean for admin-service?
Three facts shaped the answer.
The portal has no anonymous path today. Every feature route sits behind authGuard, which bounces unauthenticated visitors through a guest-session flow. That flow is not a token-only affair — it auto-creates a wp_users row and issues a JWT, so "anonymous" visitors become real user records. For a link intended to be published on an event website and forwarded through parent WhatsApp groups, that turns every casual reader into a permanent account representing nobody. The portal’s AdminServiceJwtRelayFilter reinforces this by failing closed with a 401 on any proxied call carrying no session, deliberately, so the frontend can distinguish an expired session from an anonymous caller.
admin-service’s existing public endpoints are ad hoc. /api/memberships/attachment/ (UUID-as-secret), /api/membership-types/ and /api/form** are each individually listed as permitAll beside a comment. There is no prefix that means "unauthenticated", so each new one is a judgement call made in isolation, and reviewing "what is public?" means reading a filter chain.
Organisation scope cannot come from the request. ADR-0009 holds that scope is derived by the server from its own grants and schema, and states plainly that a request carrying no principal-derived organisation identity has no organisation identity. An early iteration of this design had the portal gateway forward the host-resolved registration_system_id as a header and had the endpoint reject events belonging to any other organisation. From admin-service’s side of the boundary that header is a client field, which is the shape ADR-0009 forbids — and the same shape as the 2026-07-29 incident behind it, where a match_token was written against an organisation taken from a request body with no user JWT, the value being a WCSC tenant id that coincidentally named a live registration system. The header this design proposed forwarded a WCSC registration system id.
It also did not work. A caller addressing admin-service directly could set any value, so it stopped casual eventId editing and nothing else.
The forces: some data genuinely is public and should cost nothing to read; the mechanisms that make data readable must not quietly also make it writable or identifiable; and a control that constrains only the well-behaved is worse than no control, because it is mistaken for one.
2. Decision
Data that is public is served by a dedicated unauthenticated surface, and its exposure is bounded by what the projection selects rather than by who is asking. Four rules follow.
-
One unauthenticated prefix, and only one.
/api/public/**is the sole public surface on admin-service,permitAllas a prefix rather than per endpoint. Everything under it is read-only, returns a reduced-field projection, and must remain safe to serve to an arbitrary internet caller. Nothing outside it is public. Existing ad-hoc public endpoints are not migrated by this ADR, but no new ones are added outside the prefix. -
A public endpoint asserts no organisation, and none is supplied. Consistent with ADR-0009, the absence of a principal means the absence of an organisation identity. No header, query parameter or body field supplies one. Resources are addressed by their own identifier and resolved from the schema; the owning organisation is whatever the schema says, and is not compared against anything in the request.
-
Exposure is controlled by the projection, not by access control. A public endpoint selects the exposed columns explicitly, so that withheld data is never read rather than read-and-filtered. For the participant roster this means a JPQL constructor projection whose generated SQL does not touch
date_of_birth,genderoridentity_numberat all. The guarantee is a property of the query and is asserted directly in the resource integration test. -
The portal exempts the prefix from the JWT relay’s fail-closed behaviour, by the same mechanism already applied to proxied
management/paths. The relay filter’s401exists to make session expiry legible to the frontend; it has no meaning for a request that never had a session.
Because rules 2 and 3 together mean any event’s roster is readable, the successor control is a schema-owned visibility flag on Event that public endpoints gate on. That is the ADR-0009-consistent answer — scope from the server’s own schema — and is tracked as Feature #963 under Epic #34. This ADR records the interim state honestly rather than dressing it up.
3. Consequences
3.1. Positive
-
"What is public?" becomes a one-line question — read the prefix — instead of an audit of a filter chain and a set of per-method annotations.
-
The guest-session flow stops being load-bearing for genuinely public reads, so a widely-shared link no longer mints a user account per reader.
-
Withheld fields cannot leak through a later DTO refactor, a serialisation change, or a debug endpoint, because the query never selects them.
-
The decision not to scope is visible. A reviewer sees "no organisation filter" and can weigh it, rather than seeing a header check and assuming it holds.
-
ADR-0009 gains a worked boundary case: it is not only about which organisation a client may name, but about surfaces where no organisation identity exists at all.
3.2. Negative
-
Until the visibility flag ships, every event’s roster is publicly readable — all organisations, past events, and events not yet open. The reduced field set is the only control. This is the substantive cost, accepted knowingly and with a named successor.
-
A
permitAllprefix is a standing invitation to put the wrong thing behind it. The prefix makes public endpoints easy to add, and easy is the failure mode. Anything added under it needs the same projection discipline, and review should treat a new/api/public/**route as a security change. -
The endpoint is reachable directly on admin-service’s own hostname, not only through the portal, so it must stand on its own without gateway assistance.
-
Rate limiting and abuse protection are not addressed here and would need separate treatment if the surface grows.
3.3. Neutral
-
The gateway continues to attach
X-API-KEYto proxied calls including these; the endpoint neither needs nor uses it. That authority short-circuits the shared security specifications, which is a reason a public endpoint must not filter results through them — they would return everything and appear to work. -
404rather than403for a missing resource keeps the endpoint from becoming an existence oracle, though with no scoping in place there is presently little to reveal. -
Nothing here changes the authenticated surface.
/api/**remainsauthenticated(), and the composite organisation-and-person filtering on the secured participant endpoints is untouched.
4. Alternatives Considered
4.1. Alternative A: Reuse the guest-session flow
The path of least change: leave the route behind authGuard and let the existing bounce through /login establish a guest session, so the endpoint stays isAuthenticated() and no new surface exists. Rejected because the "session" is a real user record — a wp_users row per visitor to a link meant for wide publication — and because it would make an endpoint appear protected while being reachable by anyone willing to click through. It would also leave the organisation question unresolved, since a guest principal carries the tenant’s organisation and the endpoint would then be filtering by a value derived from whichever host the reader happened to use.
4.2. Alternative B: Host-derived organisation scoping via a gateway header
The original design. The portal resolves the tenant from the hostname already, so forwarding registration_system_id costs one filter, and it prevents one tenant’s host from serving another organisation’s roster. Rejected on both principle and effect: it is the client-supplied organisation identity ADR-0009 forbids, and it constrains only callers who go through the portal. It would become viable if admin-service were unreachable except through the gateway and the header were carried in a channel the client cannot influence — neither is true today.
4.3. Alternative C: Ship the visibility flag first
The complete answer, and the one this ADR names as the successor. Rejected as the first step only on sequencing: it is a change to the event-database schema, which brings a Liquibase changeset, a version bump, and the ordering constraint that the database release must precede the admin-service release that consumes it. Doing it first would delay a screen whose value is immediate and whose exposed field set is deliberately non-sensitive. The risk accepted in the interim is bounded by rule 3 and is explicitly time-limited.
4.4. Alternative D: Keep adding individually-listed public endpoints
What the codebase does today. Rejected because it does not scale as a review artefact: each addition is locally reasonable and the aggregate is only visible by reading SecurityConfiguration in full. The prefix converts a recurring judgement call into a single reviewable boundary.
5. References
-
Design: Public Participant List — the first consumer of the prefix
-
Related: ADR-0009 (organisation scope is server-derived and schema-owned)
-
Design journal:
design-journal/2026-07/tenant-derived-org-context.adoc(the 2026-07-29 incident) -
Code:
admin-service/…/config/SecurityConfiguration.java(the prefix),registration-portal/…/web/filter/AdminServiceJwtRelayFilter.java(the relay exemption) -
ADO: Epic #34; Feature #957 (public participant list, US #958–#962); Feature #963 (event participant-list visibility flag — the successor control)