Content Management that can withstand the plundering hoards

Visual CMS

An authenticated, browser-based visual content management system for nontechnical website owners. Users do not need to understand Kubernetes, HTML, CSS, JavaScript, templates, YAML, Git, container images or deployment pipelines.

Purpose and UX

Prioritise content editing over technical configuration. The core flow is: open the CMS, sign in, select a page, click content to edit it, add images or components, preview changes, save a draft, publish. Advanced design controls should not obstruct ordinary editing tasks.

Visual editors: Tiptap edits Markdown and GrapesJS edits HTML/templates, with DOMPurify sanitisation and safe defaults (scripts/unsafe attributes disabled, no outbound Font Awesome CDN/telemetry) so it runs cleanly under the CMS Content-Security-Policy. See editor internals.

Features

Implemented capabilities include rich/source Markdown, visual HTML layout, inline text and drag-and-drop blocks, page/menu management, media, theme layouts/shared parts, responsive preview, drafts, publication, site settings, site-level SEO fields, sitemap/robots generation, WordPress import, raw downloads, repeated listings and local block/renderer/theme packages. The current Page API has no per-page SEO-description field.

Role allowlists gate CMS access and publish/rollback; they do not provide per-page/subtree permissions. Scheduled publishing, collaborative editing, dynamic contact forms and a hosted marketplace remain proposals. See implementation status and API.

Storage

The CMS uses ordinary filesystem storage on the private volume; no database is required for the MVP. Layout per Contracts. Editable project state is preserved independently of generated HTML; generated output is never treated as the authoritative source.

Runtime

The CMS backend is Go (CGO_ENABLED=0, scratch runtime). Third-party dependencies implement OAuth2/OIDC, Markdown, HTML/CSS sanitization, WordPress conversion/MySQL and bounded WASI rendering. The complete inventory is cms/go.mod; rationale and source boundaries are in the repository map. Because the CMS talks to the identity provider and the Kubernetes API over HTTPS, its image carries the system CA bundle. Compiled browser assets are served by the Go backend at /; the editor needs no separate production runtime.

Authentication (built-in OAuth2/OIDC client)

The CMS is the authentication boundary: it embeds an OAuth2/OIDC client that guards the whole :8080 listener, so the CMS Ingress only terminates TLS and carries no auth annotations. Configuration comes from the Secret referenced by spec.cms.authentication.secretRef (see Contracts); the operator mounts it read-only at /etc/viking/auth and sets VIKING_AUTH_SECRET_DIR and VIKING_COOKIE_SECURE=true. If the configuration is missing or invalid the manager is not ready: admin /readyz reports 503 and the protected surface refuses to serve — the CMS never runs unauthenticated.

Public endpoints:

Protected surface:

Bearer tokens (machine clients): Authorization: Bearer is validated as a JWT via the provider JWKS (issuer always checked; audience = audience or client id) or, when that fails and introspectionURL is set, via RFC 7662 introspection.

Publishing gate: when publishRoles is set in the auth Secret, POST /api/publish and POST /api/publish/rollback require the user to hold at least one of those roles (403 otherwise); unset, any allowedRoles user may publish.

IdP endpoints are HTTPS-only (discoveryURL/authURL/tokenURL/jwksURL/ issuer/userinfoURL/introspectionURL); plain http:// is accepted only for loopback hosts with the development flag --allow-insecure-auth-http / VIKING_ALLOW_INSECURE_AUTH_HTTP=true. --cms-hostname / VIKING_CMS_HOSTNAME (or the cmsHostname secret key) pins the external CMS hostname used to build OAuth2 redirect URIs. The fixed-CSRF-token mode was removed — CSRF is always the per-session cookie + header + Origin scheme below.

The session cookie (viking_session) is normally self-contained and encrypted: claims are AES-256-GCM encrypted (key derived from jwtSecret) and the blob is HMAC-SHA256 signed — opaque to anyone who can read cookies (see Contracts).

Large identities use an encrypted reference cookie backed by a bounded local cache, so those sessions need login again after restart/eviction.

The short-lived login state store is held in memory, so the CMS runs as a single replica (the operator deploys replicas: 1). See Publisher.

CSRF protection (anti-CSRF, never authorization)

GET /api/publish/status is the CSRF bootstrap: it sets a per-session cookie (viking_csrf, Path=/, HttpOnly, SameSite=Strict, plus Secure when --csrf-secure/VIKING_CSRF_SECURE) and returns a random per-session token in both the X-CSRF-Token header and the csrfToken JSON body field. Repeated status GETs reuse the session rather than rotating it.

Every browser-session mutating /api/* request must present:

  1. the session cookie (naming a known, unexpired session),
  2. an X-CSRF-Token header matching that session's stored token (compared constant-time), and
  3. a same-origin Origin (preferred) or Referer header.

Verified explicit Bearer identities are exempt from the API cookie/token requirement, but supplied Origin/Referer headers are still checked. Missing origin headers are accepted for non-browser clients. Any mismatch returns 403. This is anti-CSRF only, never authorization: the built-in auth middleware (session or Bearer) authenticates the user before these handlers run. CSRF sessions are held in memory, so the CMS must run as a single replica (see Publisher). The CSRF check runs after authentication, so the CSRF bootstrap (/api/publish/status) is reached by an already-authenticated session.

Security

The CMS validates all file uploads, restricts filesystem access, prevents path traversal, applies CSRF protections, validates publication requests, avoids exposing private project files, restricts access to publishing operations, uses secure session handling and avoids arbitrary command execution. Authentication itself is enforced by the built-in OAuth2/OIDC client, which is a boundary but not a substitute for treating browser input as untrusted.

Concrete hardening applied by the CMS:

See Security and Authentication.

Status write (websites/status)

When VIKING_WEBSITE_NAMESPACE/VIKING_WEBSITE_NAME (or --website-namespace/ --website-name) are set, the publisher best-effort PATCHes the active release onto the Website status subresource after each activation (publish or rollback):

PATCH /apis/cms.tlspu.com/v1alpha1/namespaces/{ns}/websites/{name}/status
Content-Type: application/merge-patch+json
Authorization: Bearer <service-account token>

{"status":{"currentRelease":"000001"}}

This is asynchronous and does not fail a publish. A single worker re-reads the active pointer at startup, on activation and every five seconds, retrying failures so stale notifications cannot overwrite newer releases. The service account token is re-read for every call (projected tokens rotate). Synchronization is disabled without a Website identity; configured missing tokens/API errors are logged and retried. The CMS pod automounts its ServiceAccount token to perform this write; the generated <site>-cms-status Role limits it to get/patch/update on websites/status only. The legacy annotation cms.tlspu.com/current-release is no longer written.

API

JSON under /api/*; the SPA is served at /. Endpoints per Contracts.