Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Content-only frozen snapshots, explicit target mappings and dry-run diffs with verified bundle references.
- Atomic baseline/revision-guarded page batches and rollback that preserve newer edits; requires the explicit host content-revision migration.
- Transactional saved page history retains changed-page before/after content and trusted host-supplied actors for page writes, batches and rollback; requires the explicit history migration and coordinated host adoption.
- Tenant-authorized history readback provides bounded cursors and an adoption-start marker. Refused stale writes append no history; pre-adoption edits and unsaved drafts are not reconstructed.

### Changed

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ including `body_blocks`, `template_id`, `publish_at`, and `unpublish_at`.
`body_blocks` is the canonical editor document when present; `body_html` remains
the backward-compatible fallback for older pages.

Saved write history and optimistic save/delete preconditions are documented in
[Saved page history](docs/saved-page-history.md). Adoption requires both explicit
plugin SQL prerequisites and matched host schema/authorization support. It does
not reconstruct earlier edits or unsaved browser drafts.

## Install

```yaml
Expand Down
91 changes: 91 additions & 0 deletions docs/saved-page-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Saved page history and concurrent editing

This is an additive dependency on the content-promotion implementation. It is
source support, not an applied host migration or runtime adoption.

Every successful managed page create, update, delete, batch apply and batch
rollback appends one saved history event in the same transaction as the page
mutation and monotonic tenant revision. Each event records the changed pages'
before/after versions and full content (including blocks, HTML, schedules,
template and status), content scope, operation, UTC saved time and actor label.
Deletes retain their former saved content. Unchanged pages are omitted from
batch events. A saved version-only edit is still recorded.

The host supplies an authenticated stable subject through its existing
AuditActor callback. Direct store clients use WithPageWriteActor only after
their own authentication and authorization. JSON, arbitrary headers and
unverified token claims must never set that context value. Missing or invalid
labels are recorded as unattributed. This metadata grants no permissions.
Digests detect content corruption; they are not signatures or proof of the
actor's authority. The existing audit logger remains separate.

## Read saved state

GET /api/v1/admin/tenants/:tid/pages/history accepts canonical after_revision
(exclusive, default 0) and limit (1–100, default 20). Both request authorization
and current tenant access callbacks are required, including direct AdminAPI
use. Configure the host to authorize this route with the same tenant-scoped
page-read authority as ordinary private page reads. Responses are private,
no-store and include the content scope, adoption marker, current tenant
revision, immutable events, next cursor and has_more. They include drafts, so
they must never be exposed on a public content route.

Use the saved page's ID, Version and content digest to compare a fresh page read
with its latest applicable history event. Read more pages of history until
has_more is false; current_revision is an atomic head for each response. Repeat
from that head if new saved writes occur during pagination. PostgreSQL uses a
read-only repeatable-read transaction; reconnecting does not lose the history.

PUT and DELETE continue to require the editor's loaded expected_version.
Missing preconditions fail; stale versions return 409 without a page write,
revision increment or history event. The caller must retain the draft and
review a fresh read. Never retry a stale save with a substituted fresh version.

## Explicit adoption and retention

1. Review and install the first content-revision SQL prerequisite, then the
additional store/postgres/migrations/0002_page_saved_history.up.sql under
the same exclusive write fence. The second prerequisite records each
tenant's current revision as history_start_revision. It creates no past
actor claims or content snapshots. The first event is the next revision.
2. Ship matched plugin and host authorization/callers. Replace every old page
writer before releasing the fence. Missing history schema or an unrecorded
cooperative-writer revision gap fails closed and rolls back the entire
managed write. Reconcile gaps from trustworthy evidence; never reset the
marker to disguise missing events.
3. Include pages, cms_page_revisions and cms_page_history in database backups.
Verify content scope, start marker, saved head and history on restore.
History is append-only through database UPDATE/DELETE/TRUNCATE triggers.
Tenant deletion is blocked while retained history references its ledger;
privileged retention or erasure requires a separate reviewed procedure.
4. Extend the host's versioned schema and auth-continuity admission explicitly
for this second prerequisite. The existing clean10→clean11 admission
prepared for the first revision ledger does not cover it. No migration,
plugin version pin, release, auth-baseline rewrite or deployment is performed
by this library or change.

All participating writers must use this store; arbitrary external SQL page
edits are not covered by the cooperative store transaction contract.
Schema owners can bypass triggers, so this is not a tamper-proof audit service.
There is no public restore endpoint or automatic retention deletion.

## Unsaved drafts

Database history verifies committed saves after adoption. It cannot reveal
text that exists only in a browser tab, reconstruct pre-adoption editing
history, or guarantee that an owner is not currently typing. An independent
unsaved-draft check needs an explicit editor autosave/presence protocol with
separate draft state, identity, expiry and conflict handling. It must not treat
an empty presence record as permission to overwrite saved content. Current
CAS protects the next saved write regardless of such presence.

## Validation

The shared memory/PostgreSQL contract covers snapshots, trusted actor labels,
deletion, pagination, deep-copy isolation, stale-save refusal, batch/rollback
history and replay refusal. Actual PostgreSQL fixtures use unique task-owned
schemas and check reconnect durability, adoption without fabricated backfill,
append-only triggers, missing-schema/mixed-writer fail-closed behavior,
concurrent save winners and transaction rollback after a late history INSERT
failure. HTTP fixtures cover protected readback, tenant denial, unconfigured
auth refusal, canonical queries/routes and ignored arbitrary actor headers.
36 changes: 36 additions & 0 deletions host/page_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package host

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/GoCodeAlone/workflow-plugin-cms/store"
)

func TestServerSavedHistoryActorWithoutOptionalAuditLogger(t *testing.T) {
s := New(Config{
Pages: store.NewMemoryPageStore(),
AdminAuth: func(*http.Request) bool { return true },
AdminTenantAccess: func(_ *http.Request, tid int64) bool { return tid == 234 },
AuditActor: func(*http.Request) string { return "user:verified-owner" },
})
if s.Audit() != nil {
t.Fatal("fixture unexpectedly enabled signed logger")
}
base := "/api/v1/admin/tenants/234/pages"
r := httptest.NewRequest(http.MethodPost, base, strings.NewReader("{\"path\":\"/saved\",\"title\":\"Saved\",\"status\":\"draft\"}"))
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != 201 {
t.Fatalf("actual host create: %d", w.Code)
}
w = httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, base+"/history", nil))
var h store.PageHistory
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &h) != nil || len(h.Entries) != 1 || h.Entries[0].Actor != "user:verified-owner" {
t.Fatal("host lost verified history actor without separate signed logger")
}
}
2 changes: 1 addition & 1 deletion host/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func New(cfg Config) *Server {
api.TenantAccess = cfg.AdminTenantAccess
api.PlatformAccess = cfg.AdminPlatformAccess
api.RequestAccess = cfg.AdminAuth
api.AuditActor = cfg.AuditActor
api.ResolveTemplate = s.resolveTenantTemplate
api.ListTemplates = s.listTenantTemplates
s.admin = api
Expand All @@ -169,7 +170,6 @@ func New(cfg Config) *Server {
if cfg.AuditSignKey != "" {
s.audit = audit.New(cfg.AuditSignKey, cfg.AuditSink)
s.admin.Audit = s.audit
s.admin.AuditActor = cfg.AuditActor
}
s.adminRoot = adminui.Handler()
s.adminUI = http.StripPrefix("/admin", s.adminRoot)
Expand Down
11 changes: 6 additions & 5 deletions internal/admin_contribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ func CMSAdminContributionMetadata(authorized bool) map[string]string {
return map[string]string{}
}
return map[string]string{
"sites_path": "/api/v1/admin/tenants",
"pages_path": "/api/v1/admin/tenants/{tenant_id}/pages",
"templates_path": "/api/v1/admin/tenants/{tenant_id}/templates",
"overlays_path": "/api/v1/admin/tenants/{tenant_id}/overlays",
"launch_edit_path": "/api/v1/admin/tenants/{tenant_id}/edit-session",
"sites_path": "/api/v1/admin/tenants",
"pages_path": "/api/v1/admin/tenants/{tenant_id}/pages",
"page_history_path": "/api/v1/admin/tenants/{tenant_id}/pages/history",
"templates_path": "/api/v1/admin/tenants/{tenant_id}/templates",
"overlays_path": "/api/v1/admin/tenants/{tenant_id}/overlays",
"launch_edit_path": "/api/v1/admin/tenants/{tenant_id}/edit-session",
}
}
7 changes: 6 additions & 1 deletion internal/admin_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func (a *AdminAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, 403, "forbidden", "tenant access denied")
return
}
if a.AuditActor != nil && (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete) {
r = r.WithContext(store.WithPageWriteActor(r.Context(), a.AuditActor(r)))
}
switch {
case route == "/reload" && r.Method == "POST":
a.reload(w, r)
Expand Down Expand Up @@ -108,6 +111,8 @@ func (a *AdminAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.pageTemplates(w, r, tid)
case route == "/pages/permissions" && r.Method == "GET":
a.pagePermissions(w, r, tid)
case route == "/pages/history" && r.Method == "GET":
a.pageHistory(w, r, tid)
case route == "/overlays/clone" && r.Method == "POST":
a.cloneOverlay(w, r, tid)
case route == "/overlays/publish" && r.Method == "PUT":
Expand Down Expand Up @@ -151,7 +156,7 @@ func parseAdminRoute(path string) (route string, tid, rid int64) {
}
route = "/" + parts[2] + "/" + parts[3]
switch route {
case "/pages/preview", "/pages/templates", "/pages/permissions", "/overlays/clone", "/overlays/publish", "/overlays/disable", "/nav/published", "/widgets/render", "/media/validate":
case "/pages/preview", "/pages/templates", "/pages/permissions", "/pages/history", "/overlays/clone", "/overlays/publish", "/overlays/disable", "/nav/published", "/widgets/render", "/media/validate":
return route, tid, 0
}
if parts[2] != "pages" && parts[2] != "domains" {
Expand Down
61 changes: 61 additions & 0 deletions internal/page_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package internal

import (
"errors"
"net/http"
"net/url"
"strconv"

"github.com/GoCodeAlone/workflow-plugin-cms/store"
)

func (a *AdminAPI) pageHistory(w http.ResponseWriter, r *http.Request, tenantID int64) {
w.Header().Set("Cache-Control", "private, no-store")
// Historical drafts contain private content. A new read endpoint must not
// inherit the legacy direct-handler assumption that an outer host gates it.
if a.RequestAccess == nil || a.TenantAccess == nil {
writeJSONError(w, 503, "unavailable", "history authorization not configured")
return
}
history, ok := a.pages.(store.PageHistoryStore)
if !ok {
writeJSONError(w, 503, "unavailable", "page history not configured")
return
}
q := store.PageHistoryQuery{Limit: 20}
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
writeJSONError(w, 400, "bad_request", "canonical history query required")
return
}
for key, values := range query {
if len(values) != 1 || (key != "after_revision" && key != "limit") {
writeJSONError(w, 400, "bad_request", "canonical history query required")
return
}
n, err := strconv.ParseInt(values[0], 10, 64)
if err != nil || strconv.FormatInt(n, 10) != values[0] || n < 0 {
writeJSONError(w, 400, "bad_request", "canonical history query required")
return
}
if key == "after_revision" {
q.AfterRevision = n
} else {
if n < 1 || n > 100 {
writeJSONError(w, 400, "bad_request", "history limit must be between 1 and 100")
return
}
q.Limit = int(n)
}
}
h, err := history.ReadPageHistory(r.Context(), tenantID, q)
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, 404, "not_found", "page history not found")
return
}
if err != nil {
writeJSONError(w, 503, "unavailable", "page history unavailable")
return
}
writeJSON(w, 200, h)
}
112 changes: 112 additions & 0 deletions internal/page_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package internal

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/GoCodeAlone/workflow-plugin-cms/store"
)

func historyAPI() *AdminAPI {
api := newAdminTestAPI()
api.RequestAccess = func(*http.Request) bool { return true }
api.TenantAccess = func(_ *http.Request, id int64) bool { return id == 234 }
api.AuditActor = func(*http.Request) string { return "user:authenticated-owner" }
return api
}

func historyResponse(t *testing.T, api *AdminAPI, path string) (*httptest.ResponseRecorder, store.PageHistory) {
t.Helper()
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
api.ServeHTTP(w, r)
var h store.PageHistory
if w.Code == 200 && json.Unmarshal(w.Body.Bytes(), &h) != nil {
t.Fatal("invalid history JSON")
}
return w, h
}

func TestAdminSavedPageHistoryAndTrustedActor(t *testing.T) {
api := historyAPI()
base := "/api/v1/admin/tenants/234/pages"
w, p := doJSON(t, api, http.MethodPost, base, map[string]any{"path": "/rock-metal", "title": "Rock", "body_html": "<p>Saved</p>", "status": "draft"})
if w.Code != 201 {
t.Fatal(w.Code)
}
w, _ = doJSON(t, api, http.MethodPut, base+"/1", map[string]any{"expected_version": p["Version"], "title": "Updated"})
if w.Code != 200 {
t.Fatal(w.Code)
}
w, h := historyResponse(t, api, base+"/history?limit=1")
if w.Code != 200 || w.Header().Get("Cache-Control") != "private, no-store" || len(h.Entries) != 1 || !h.HasMore || h.CurrentRevision != 2 || h.Entries[0].Actor != "user:authenticated-owner" || h.Entries[0].After[0].Content.BodyHTML != "<p>Saved</p>" {
t.Fatal("protected saved history incorrect")
}
w, h = historyResponse(t, api, base+"/history?after_revision=1&limit=1")
if w.Code != 200 || len(h.Entries) != 1 || h.Entries[0].Before[0].Content.Title != "Rock" || h.Entries[0].After[0].Content.Title != "Updated" {
t.Fatal("saved update readback incorrect")
}
// Neither JSON nor an arbitrary actor header can supply trusted identity.
w, _ = doJSON(t, api, http.MethodPut, base+"/1", map[string]any{"expected_version": 2, "title": "Forged", "actor": "superadmin"})
if w.Code != 400 {
t.Fatal("JSON actor accepted")
}
api.AuditActor = nil
r := httptest.NewRequest(http.MethodPut, base+"/1", strings.NewReader("{\"expected_version\":2,\"title\":\"Header ignored\"}"))
r.Header.Set("X-Actor", "superadmin")
w = httptest.NewRecorder()
api.ServeHTTP(w, r)
if w.Code != 200 {
t.Fatal(w.Code)
}
w, h = historyResponse(t, api, base+"/history?after_revision=2")
if w.Code != 200 || len(h.Entries) != 1 || h.Entries[0].Actor != "unattributed" {
t.Fatal("untrusted header fabricated identity")
}
w, _ = doJSON(t, api, http.MethodDelete, base+"/1", map[string]any{"expected_version": 3})
if w.Code != 204 {
t.Fatal(w.Code)
}
w, h = historyResponse(t, api, base+"/history?after_revision=3")
if w.Code != 200 || len(h.Entries) != 1 || h.Entries[0].Operation != "delete" || h.Entries[0].Before[0].Content.Title != "Header ignored" {
t.Fatal("deleted page history missing")
}
}

func TestAdminHistoryFailsClosedAndCanonicalRoutes(t *testing.T) {
path := "/api/v1/admin/tenants/234/pages/history"
for _, missing := range []string{"request", "tenant"} {
api := historyAPI()
if missing == "request" {
api.RequestAccess = nil
} else {
api.TenantAccess = nil
}
w, _ := historyResponse(t, api, path)
if w.Code != 503 {
t.Fatal("unconfigured history authority allowed")
}
}
api := historyAPI()
api.RequestAccess = func(*http.Request) bool { return false }
if w, _ := historyResponse(t, api, path); w.Code != 403 {
t.Fatal("denied request allowed")
}
api = historyAPI()
if w, _ := historyResponse(t, api, "/api/v1/admin/tenants/235/pages/history"); w.Code != 403 {
t.Fatal("foreign tenant history allowed")
}
for _, query := range []string{"?limit=0", "?limit=101", "?limit=01", "?after_revision=-1", "?after_revision=01", "?limit=1&limit=2", "?actor=x", "?limit=1;bad", "?limit=%zz"} {
if w, _ := historyResponse(t, api, path+query); w.Code != 400 {
t.Fatalf("noncanonical query allowed: %s", query)
}
}
for _, bad := range []string{"/api/v1/admin/tenants/0234/pages/history", path + "/1", path + "/history"} {
if w, _ := historyResponse(t, api, bad); w.Code != 404 {
t.Fatal("noncanonical history route allowed")
}
}
}
Loading
Loading