The owner's admin, behind Google's sign-in, beside a public API that keeps no session
One owner signs in with Google to read visits and introductions; the admin has a session cookie and CSRF tokens, while the public API keeps none, and visitors still get no cookie.
Context
Visits (ADR 0014) and introductions
(ADR 0015) are kept for one reader, the site's owner, who needs a way
to read them that nobody else has. The public API has no accounts, no session and no cookie
(ADR 0004, ADR 0007), and every page of the lab calls
it; whatever guards the admin must not change a byte of that. The load balancer routes only /api/* to the service
(ADR 0002).
Decision
- Two filter chains. Spring Security's first chain takes
/api/admin/**and/api/auth/**: a session, CSRF on every change, and the owner alone past the door. The second takes everything else as it was: no session, no CSRF, no logout address and none of Spring Security's headers (the load balancer sets the site's), so the public API answers exactly as before. Spring Security's own observations are off: in a lab run's trace they are noise around the request. - Google's sign-in, under
/api/auth/. OAuth2 login with Google, its endpoints moved under/api/auth/where the load balancer sends them; the Google client's redirect URI ishttps://ghazaryantech.com/api/auth/oauth2/code/google. Where no Google client is configured, the sign-in is simply absent. A sign-in that lets nobody in comes back to/admin/?refusedwhen the account is not the owner's and to/admin/?failedotherwise (cancelled at Google, or a return no sign-in had started). Redirects are relative: behind the load balancer the service sees plain HTTP, and an absolute address would send the browser tohttp://. - An allow-list, asked twice.
app.admin.ownerslists the owner's Google addresses. A sign-in from any other address, or from one Google has not verified, is refused before a session is the visitor's; every admin request checks the address again. - One cookie, the owner's.
__Host-admin: Secure, HttpOnly, SameSite=Lax (Lax because the sign-in comes back from Google by a top-level redirect, which Strict would send without it), bound to this host. It lives in the one instance's memory and ends with a cold start; the owner signs in again with a click. - CSRF by header.
GET /api/admin/meanswers with who is signed in and the token; every change sends it back inX-CSRF-TOKEN. Refusals are RFC 9457 problems like the rest of the API's:UNAUTHENTICATED,NOT_THE_OWNER,CSRF_TOKEN_INVALID. - A development login that cannot reach production. Under the
dev-loginprofile,GET /api/auth/dev-loginsigns in as the first owner without Google, so the admin can be worked on locally; the application refuses to start with that profile andprodtogether. - A client-only page.
/admin/is prerendered as an empty shell,noindexand outside the sitemap; it asks the API who is signed in and shows either the ways in or the visits and introductions; a session that ends while it is open takes it back to the ways in, and an API it cannot reach is said to be so, not taken for a signed-out owner. Its own views are not counted as visits. Its endpoints are left out of the public API reference.
Consequences
- Visitors still get no cookie. Whoever opens
/admin/and starts a sign-in gets the session for the length of it, and the owner keeps it; ADR 0007 names this as its one exception, and the privacy page says so. - Signing in needs a Google OAuth client (an id and a secret in Secret Manager) and the owner's address in
APP_ADMIN_OWNERS; without them the admin cannot be entered in production, and nothing else changes. - The session is in the memory of the one instance (
--max-instances 1): when Cloud Run stops it after a quiet spell, or a deploy replaces it, the owner signs in again. - The admin reads what S19 and S20 keep and changes only introductions: handled, or deleted on a sender's request.
- Spring Security costs every cold start about a quarter of a second (a median of 4.11 s before, 4.38 s after, five
alternating starts each on one vCPU) and the image 17 MB
(
docs/measurements/raw/2026-10-03-security-startup.txt): a page for one reader, paid for by every visitor who wakes the service. A service of its own would keep that off the public API, for the price of a second deployment beside the same database; not worth it at this size.