Content Management that can withstand the plundering hoards

Shared implementation contracts

Use the canonical Website reference for administrator settings, names, images, ports and status. Source types and the installed CRD define validation. Keep public guidance aligned with interface changes; historical plans are context, not a second specification.

1. Custom Resource

The authoritative schema is controller/config/crd/bases/cms.tlspu.com_websites.yaml. Update controller types/schema first, then mirror changes to deploy/01-crd.yaml and external Orbit copies. Preserve validation, defaults and upgrade compatibility described in Website settings.

Authentication Secret

The CMS reads one file per key under /etc/viking/auth, mounted read-only with mode 0444. Keep operator validation and CMS parsing aligned with the canonical Secret reference. Invalid auth must block protected serving and readiness.

2. Resource naming

Preserve names, ownership, retained PVC identity and automatic identity records across reconciliation. See generated names and operator architecture. Status RBAC grants get/patch/update on its own Website's status subresource by resource name.

3. Container images

See image configuration and build/test for production artifacts. Runtime components use static Go binaries, non-root scratch containers and read-only root filesystems. The CMS image carries the CA bundle for IdP/Kubernetes HTTPS and builds/tests its own editor. Node is a build dependency rather than a runtime.

4. Ports and endpoints

Canonical ports and probe behaviour are customer-facing interfaces. Every HTTP listener must apply the component's buildinfo.WithServerHeader outside authentication, using the binary's stamped version, with successful and rejected response checks. Standard probes include status/version JSON and Cache-Control: no-store; preserve status-code semantics.

5. Published filesystem layout

See the canonical published layout and publisher for immutable release, activation, history and recovery contracts.

6. CMS private layout

See the canonical private layout and persistence internals for source authority, durable mutations, recovery journals and snapshot lifetimes.

7. CMS API and authentication

JSON under /api/*; the SPA is served at /. The CMS backend is the authentication boundary: it runs a built-in OAuth2/OIDC client, so every request on the public :8080 listener is authenticated (the ingress performs TLS offload only). Public auth endpoints and the authenticated API:

IdP endpoints are HTTPS-only: discoveryURL, authURL, tokenURL, jwksURL, issuer, userinfoURL and introspectionURL must be https://. Plain http:// is accepted only for loopback hosts (localhost, 127.0.0.1, [::1]) and only with the CMS flag --allow-insecure-auth-http / VIKING_ALLOW_INSECURE_AUTH_HTTP=true (development only); without it the CMS refuses to start.

Unauthenticated behaviour: /api/* returns 401 (JSON); a browser-like navigation to a non-/api path is redirected (302) to /oauth2/start; when the auth configuration is missing or invalid the whole protected surface returns 503 and the CMS never runs unauthenticated.

Session cookie — viking_session (configurable via cookieName): a normally stateless encrypted value base64url(nonce‖AES-256-GCM-encrypted-claims).base64url(HMAC-SHA256(jwtSecret, blob)) containing email, name, roles, iss/sub, exp/iat, a random jti and an ID-token hint retained for logout. The claims are AES-256-GCM encrypted (key derived from jwtSecret) and the whole blob is HMAC-SHA256 signed, so the cookie is opaque to anyone who can read it. It is HttpOnly, SameSite=Lax and Secure (the operator sets VIKING_COOKIE_SECURE=true; the CMS defaults to Secure).

Large session payloads use an encrypted reference cookie rather than exceeding browser limits. The full identity (including all roles) and logout token hint stay in a bounded in-memory cache (1,000 sessions, 16 MiB total, 256 KiB maximum per identity). These reference sessions require login again after a CMS restart or cache eviction. Small sessions remain self-contained; cookie name plus value is kept below 3,800 bytes.

Bearer tokens (machine clients) — an Authorization: Bearer header wins over the session cookie. The token is validated as a JWT against the provider JWKS (issuer always checked; audience = audience when set, else the client id), or, when that fails and introspectionURL is set, via RFC 7662 introspection (client-credentials basic auth; the token must come back active).

RBAC — when allowedRoles is set, the user must have at least one matching role (from rolesClaim); otherwise any authenticated user passes. When publishRoles is set, POST /api/publish and POST /api/publish/rollback additionally require one of those roles (403 otherwise). RBAC applies to browser sessions and Bearer tokens alike; denial returns 403.

CSRF — GET /api/publish/status is the CSRF bootstrap: it sets a per-session cookie (viking_csrf, HttpOnly, SameSite=Strict, Secure when --csrf-secure) and returns a random per-session token in the X-CSRF-Token header and the csrfToken body field. Browser-session mutations must present the session cookie, a matching X-CSRF-Token, and a same-origin Origin/Referer. Verified Bearer identities are exempt from the cookie/token requirement on /api/* mutations; an unverified Authorization header never grants exemption. Same-origin checks apply to supplied Origin/Referer headers for all clients. This is anti-CSRF only, never authorization — authorization is the auth middleware described above. The short-lived login state store and CSRF sessions are held in memory, so the CMS runs as a single replica. Supplied Origin or Referer headers are checked; CLI requests without either header have no origin context to reject. Browser clients should send the normal same-origin headers.

The admin listener (:8081) /healthz, /readyz and /metrics stay unauthenticated so health checks and scraping do not need credentials.

8. Go modules

github.com/tlspu/viking-cms/controller
github.com/tlspu/viking-cms/frontend
github.com/tlspu/viking-cms/cms

A root go.work at the repository root declares use (./controller ./frontend ./cms) so the modules build together while remaining independent.

9. CMS authentication (built-in OAuth2/OIDC client)

The CMS is the authentication boundary: it runs the OAuth2/OIDC client itself, so the ingress does not authenticate and carries no auth annotations (it terminates TLS only). There is exactly one mechanism — a reference to the Secret the CMS reads its client configuration from (cms.authentication.secretRef), see the key table in section 1. The operator mounts the Secret read-only, sets the auth env vars on the CMS Deployment, and validates the required keys; it must reject configurations that would expose the CMS without effective authentication (missing Secret, empty authentication, hostname without authentication) rather than silently falling back to unauthenticated access. The generated CMS Ingress is a plain routing Ingress.

Introspection responses must include active: true, iss matching the configured provider issuer, and aud (a string or array) containing the configured audience, or the client ID when no audience is configured. Present exp and nbf claims are also enforced. Active responses with missing or mismatched issuer/audience are rejected, including JWTs rejected by local verification. Configure the provider to include these claims before upgrading clients that use opaque tokens. Browser sessions and ordinary JWT verification are unaffected by this introspection requirement.

Asset delivery and session recovery

Every newly published asset has an immutable api/assets/<id>/metadata.json containing {"contentType":"<validated media type>"} beside its content file. The frontend accepts only supported raster image, plain text and JSON types from metadata and serves documents as attachments; raw uploads remain application/octet-stream attachments with their stored filename. The /api/assets namespace is reserved and cannot contain page drafts. Older releases without metadata retain generic MIME delivery until republished.

GET /api/me also returns issuer and subject, identifying the authenticated principal independently of optional profile fields. On expiry, the editor lets the user continue local editing while server mutations remain blocked. Explicit login uses the current window after checkpointing authoring drafts in per-tab session storage. Recovery verifies the original principal and refreshes CSRF, restores source and metadata, then clears the checkpoint. Reauthentication never automatically saves or publishes. Credentials are excluded from checkpoints; storage failure prevents navigation from silently discarding work.