Repository navigation
2.3.0 — SilverStripe 6 line
Fixed
-
(#89)
PublishOrchestrator::findDescendantIDs()'s descendant-cascade guard used to key on
hasExtension(Hierarchy::class), but the cascade it protects against
(SiteTree::onBeforeDelete()deleting every currentAllChildren()) only fires onSiteTree
itself, and only whenSiteTree::config()->get('enforce_strict_hierarchy')is true (Hierarchy
declares noonBeforeDelete/onAfterDelete;Versionedonly hasonAfterDelete). A project
that setsenforce_strict_hierarchy: false, or aHierarchy-extended non-SiteTreemodel, was
refused with409 UNPUBLISH_STRANDS_DESCENDANTSon every ordinary unpublish/archive and had to
passforce: true— whose entire meaning is "accept the loss" — for a cascade that was never
actually going to happen. The guard now checks$record instanceof SiteTree && SiteTree::config()->get('enforce_strict_hierarchy')instead. -
(#80)
POST records/$Class/$ID/unpublishwith{"force": true}bypasses the
stranded-descendants guard, and the resulting cascade is delete-shaped — the same live-subtree
lossarchiveproduces — but was gated on theactionverb alone, unlike every other
delete-shaped path in the module (archive; the batchdeleteop withmode: "unpublish",
which already requireddeleteviaRecordWriter::delete()).RecordActionsHandlernow also
requires thedeleteverb, at both the class and record level, wheneverforce: trueis
passed tounpublish— plain, non-forcedunpublishis unchanged and still needs only
action. NewPublishOrchestrator::forceCouldStrandDescendants()gates this: per #89,force
can only ever bypass a real cascade risk on aSiteTreeclass withenforce_strict_hierarchy
enabled — on every other class the bypass is already a no-op, and demandingdeletefor it
there would itself be a breaking change for a client that defensively always sends
force: true. Exposing this as one shared method, rather than duplicating the
instanceof/config check in the handler, keeps the guard (#89) and this verb gate from being
able to silently drift apart the next time either is rescoped. -
(#114) BREAKING. A payload write's root record could publish — including
"publish": "subtree", turning one authorized write into a whole-tree publish — without ever
having itsactionverb checked.RecordWriter::upsert()/update()only ever checked
update/create; a class grantingupdatebut withholdingactionstill had its root
published.PageHandler::convert()andCompositionService::convertPage()were worse: both
checked only the pre-conversion record'supdateverb, never the target class's verbs at
all, before publishing the converted instance as root.RecordWriter::write()now also requires the class-levelactionverb whenever its payload's
publishkey is anything other thannoneon aVersionedclass — matching
PublishOrchestrator::publish()'s own no-op for a non-versioned record, so a class that can
never actually be published isn't newly required to grant a verb for it. This is the same
class-level check
PublishOrchestrator::collectSubtreeTargets()already runs for every descendant of a
subtreepublish (#90), now also covering the root.PageHandler::convert()and
CompositionService::convertPage()now check the target class'supdateverb
unconditionally, plus itsactionverb whenever the request's publish mode isn'tnone.
PageHandler::applyTemplate()gets the equivalent class-levelactioncheck for its own
"publish": "recursive"option, since it bypassesPublishOrchestratorentirely
(publishSingle()/publishRecursive()called directly) and so isn't covered by the
RecordWriterfix either.CompositionService::compose()had a third, non-conversion path to
the same gap:publishAll()calls$page->publishRecursive()directly, reachable with no
page.convertToin the payload at all, and the page's own field write (if any) never carries a
publishkey, soRecordWriter's fix above never reaches it either —compose()now checks
the page's ownactionverb beforepublishAll()runs, whenever the composition's top-level
publishis"recursive".Breaking: any class whose
api_accessgrantsupdate/createbut notaction, and that
receives writes carrying apublishkey other than"none"— including a composition's page,
whether or notconvertTois used — now 403sFORBIDDEN_CLASSwhere it previously succeeded.Not covered, and deliberately out of scope:
publishAll()also publishes the composition's
area and every element (and element child) viaPublishOrchestrator::publish($record, 'single', $member)—singlemode performs no authorization at all, and nothing upstream
checksactionfor those classes either, since element writes always pass"publish": "none"
explicitly. This is the owned-relation publish cascade #119 exists to formalize with real
authorization across a whole$ownstree, not a single root record's own verb — closing it
here would be scope creep onto that issue. Filed as #168.