summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-31 18:14:30 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-31 18:14:30 +0200
commit464af1625349d557f688da9f845471ef8b80a5f9 (patch)
treeaab415abf115f20ccbcdfad49c27ce5d1bd61134
parent8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 (diff)
Gate live-content changes on restricted surfaces
publish_draft!, trash!, destroy_from_trash!, attach_asset! and Asset#destroy_witnessed! now refuse unless the acting user holds redaktion, and only when the subject is on a restricted surface: the front page, the updates tree that feeds ~100k subscribers, or disclosure. Drafting, autosaving, tagging and creating stay free everywhere for everyone. Enforcement is in the models rather than the controllers, since attach_asset! and the rest are reachable from rake tasks and internal paths. It follows the errors.add-plus-bare-raise pattern the rest of Node already uses, so every existing RecordInvalid rescue reports it with a localised message; only assets_controller#destroy needed a rescue added. A nil user is treated as a system context and bypasses the gate. The default nil on three of those verbs is what makes that reachable, and removing those defaults once every call site passes a user is the next tightening.
-rw-r--r--app/controllers/assets_controller.rb6
-rw-r--r--app/controllers/nodes_controller.rb3
-rw-r--r--app/models/asset.rb12
-rw-r--r--app/models/node.rb28
-rw-r--r--app/models/user.rb5
-rw-r--r--config/locales/de.yml5
-rw-r--r--config/locales/en.yml1
-rw-r--r--lib/ccc_conventions.rb1
-rw-r--r--test/controllers/nodes_controller_test.rb14
-rw-r--r--test/models/asset_destroy_test.rb13
-rw-r--r--test/models/node_test.rb68
-rw-r--r--test/models/user_test.rb16
12 files changed, 172 insertions, 0 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index 988d56d1..03780c69 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -93,6 +93,12 @@ class AssetsController < ApplicationController
93 format.html { redirect_to(assets_url) } 93 format.html { redirect_to(assets_url) }
94 format.xml { head :ok } 94 format.xml { head :ok }
95 end 95 end
96 rescue ActiveRecord::RecordInvalid => e
97 flash[:error] = e.message
98 respond_to do |format|
99 format.html { redirect_to(asset_path(@asset)) }
100 format.xml { head :forbidden }
101 end
96 end 102 end
97 103
98 private 104 private
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index c56fd945..383fb72c 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -194,6 +194,9 @@ class NodesController < ApplicationController
194 @node.publish_draft!(current_user) 194 @node.publish_draft!(current_user)
195 flash[:notice] = t("flash.nodes.published") 195 flash[:notice] = t("flash.nodes.published")
196 redirect_to node_path(@node) 196 redirect_to node_path(@node)
197 rescue ActiveRecord::RecordInvalid => e
198 flash[:error] = e.message
199 redirect_to node_path(@node)
197 end 200 end
198 201
199 def unlock 202 def unlock
diff --git a/app/models/asset.rb b/app/models/asset.rb
index 8cec4371..b256b929 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -41,6 +41,13 @@ class Asset < ApplicationRecord
41 :ids => page_ids).distinct 41 :ids => page_ids).distinct
42 end 42 end
43 43
44 # An asset's reach is the reach of the pages carrying it: destroying one
45 # removes it from every live page at once, so a single restricted
46 # attachment makes the destruction a restricted act.
47 def restricted?
48 attached_nodes.any?(&:restricted?)
49 end
50
44 # Witnessed destruction. Destroying an asset is a public-facing act 51 # Witnessed destruction. Destroying an asset is a public-facing act
45 # even when unattached. The original and its variants are publicly 52 # even when unattached. The original and its variants are publicly
46 # reachable under /system/uploads, so an entry is always written, 53 # reachable under /system/uploads, so an entry is always written,
@@ -49,6 +56,11 @@ class Asset < ApplicationRecord
49 # participates as the first non-Node subject (its participant row 56 # participates as the first non-Node subject (its participant row
50 # dangles after destroy, by design, the name lives on in metadata). 57 # dangles after destroy, by design, the name lives on in metadata).
51 def destroy_witnessed! user: 58 def destroy_witnessed! user:
59 if user && !user.may_change_live?(self)
60 errors.add(:base, :not_permitted)
61 raise ActiveRecord::RecordInvalid.new(self)
62 end
63
52 ActiveRecord::Base.transaction do 64 ActiveRecord::Base.transaction do
53 affected = attached_nodes.to_a 65 affected = attached_nodes.to_a
54 headline_losses = affected.select do |node| 66 headline_losses = affected.select do |node|
diff --git a/app/models/node.rb b/app/models/node.rb
index 1823daa4..ac3a6160 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -236,6 +236,8 @@ class Node < ApplicationRecord
236 # Return nil if nothing to publish and no staged changes 236 # Return nil if nothing to publish and no staged changes
237 return nil unless self.draft || staged_slug || staged_parent_id 237 return nil unless self.draft || staged_slug || staged_parent_id
238 238
239 guard_live_change!(current_user)
240
239 if in_trash? || trash_node? 241 if in_trash? || trash_node?
240 errors.add(:base, :publish_in_trash) 242 errors.add(:base, :publish_in_trash)
241 raise ActiveRecord::RecordInvalid.new(self) 243 raise ActiveRecord::RecordInvalid.new(self)
@@ -319,6 +321,9 @@ class Node < ApplicationRecord
319 # at the root, carrying the leaving-public-view snapshot. 321 # at the root, carrying the leaving-public-view snapshot.
320 def trash! current_user = nil 322 def trash! current_user = nil
321 return nil if in_trash? 323 return nil if in_trash?
324
325 guard_live_change!(current_user)
326
322 if trash_node? 327 if trash_node?
323 errors.add(:base, :trash_the_trash) 328 errors.add(:base, :trash_the_trash)
324 raise ActiveRecord::RecordInvalid.new(self) 329 raise ActiveRecord::RecordInvalid.new(self)
@@ -391,6 +396,8 @@ class Node < ApplicationRecord
391 # One log entry at the root, per the subtree rule, written before the 396 # One log entry at the root, per the subtree rule, written before the
392 # rows die. 397 # rows die.
393 def destroy_from_trash! current_user = nil 398 def destroy_from_trash! current_user = nil
399 guard_live_change!(current_user)
400
394 unless in_trash? 401 unless in_trash?
395 errors.add(:base, :destroy_outside_trash) 402 errors.add(:base, :destroy_outside_trash)
396 raise ActiveRecord::RecordInvalid.new(self) 403 raise ActiveRecord::RecordInvalid.new(self)
@@ -485,6 +492,8 @@ class Node < ApplicationRecord
485 # Returns { :attached => n, :already => n, 492 # Returns { :attached => n, :already => n,
486 # :headline => nil | :set | :kept_existing | :not_eligible } 493 # :headline => nil | :set | :kept_existing | :not_eligible }
487 def attach_asset! asset, user:, headline: false 494 def attach_asset! asset, user:, headline: false
495 guard_live_change!(user)
496
488 if in_trash? || trash_node? 497 if in_trash? || trash_node?
489 errors.add(:base, :attach_in_trash) 498 errors.add(:base, :attach_in_trash)
490 raise ActiveRecord::RecordInvalid.new(self) 499 raise ActiveRecord::RecordInvalid.new(self)
@@ -565,6 +574,17 @@ class Node < ApplicationRecord
565 false 574 false
566 end 575 end
567 576
577 def restricted?
578 return true if root?
579
580 name = unique_name.to_s
581 return false if name.empty?
582
583 CccConventions::RESTRICTED_SUBTREES.any? do |prefix|
584 name == prefix || name.start_with?("#{prefix}/")
585 end
586 end
587
568 # Returns immutable node id for all new nodes so that the atom feed entry ids 588 # Returns immutable node id for all new nodes so that the atom feed entry ids
569 # stay the same eventhough the slug or positions changes. 589 # stay the same eventhough the slug or positions changes.
570 # Can be removed after a year or so ;) 590 # Can be removed after a year or so ;)
@@ -677,6 +697,14 @@ class Node < ApplicationRecord
677 697
678 private 698 private
679 699
700 def guard_live_change! user
701 return if user.nil?
702 return if user.may_change_live?(self)
703
704 errors.add(:base, :not_permitted)
705 raise ActiveRecord::RecordInvalid.new(self)
706 end
707
680 def reserved_slug_stays_reserved 708 def reserved_slug_stays_reserved
681 if parent&.root? && !trash_node_already_me? 709 if parent&.root? && !trash_node_already_me?
682 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG 710 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG
diff --git a/app/models/user.rb b/app/models/user.rb
index 1728521a..e8c3b9bb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -105,6 +105,11 @@ class User < ApplicationRecord
105 roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } 105 roles.map { |r| I18n.t("users.roles.#{r}", :default => r) }
106 end 106 end
107 107
108 def may_change_live?(subject)
109 return true unless subject.restricted?
110 redaktion?
111 end
112
108 def deactivate!(actor:) 113 def deactivate!(actor:)
109 return false if alumni? 114 return false if alumni?
110 transaction do 115 transaction do
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 8aae7ca5..a7ad0f26 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -145,6 +145,7 @@ de:
145 restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein" 145 restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein"
146 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" 146 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden"
147 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" 147 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden"
148 not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern"
148 user: 149 user:
149 attributes: 150 attributes:
150 roles: 151 roles:
@@ -153,6 +154,10 @@ de:
153 attributes: 154 attributes:
154 headline: 155 headline:
155 images_and_pdfs_only: "kann nur auf Bildern oder PDFs gesetzt werden" 156 images_and_pdfs_only: "kann nur auf Bildern oder PDFs gesetzt werden"
157 asset:
158 attributes:
159 base:
160 not_permitted: "Dieses Asset ist an geschützte Seiten angehängt; nur die Redaktion darf es löschen"
156 161
157 tags: 162 tags:
158 index: 163 index:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e434538f..7e830325 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -92,6 +92,7 @@ en:
92 restore_target_invalid: "Restore target must be a living node" 92 restore_target_invalid: "Restore target must be a living node"
93 destroy_outside_trash: "Nodes are only destroyed from the Trash" 93 destroy_outside_trash: "Nodes are only destroyed from the Trash"
94 attach_in_trash: "Cannot attach assets to a node in the Trash" 94 attach_in_trash: "Cannot attach assets to a node in the Trash"
95 not_permitted: "Only Redaktion members may change published content in this section"
95 user: 96 user:
96 attributes: 97 attributes:
97 roles: 98 roles:
diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb
index 7ff5a3ab..b840dee2 100644
--- a/lib/ccc_conventions.rb
+++ b/lib/ccc_conventions.rb
@@ -3,6 +3,7 @@ module CccConventions
3 ERFA_PARENT_NAME = "club/erfas" 3 ERFA_PARENT_NAME = "club/erfas"
4 CHAOSTREFF_PARENT_NAME = "club/chaostreffs" 4 CHAOSTREFF_PARENT_NAME = "club/chaostreffs"
5 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze 5 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze
6 RESTRICTED_SUBTREES = %w[updates disclosure].freeze
6 7
7 NODE_KINDS = { 8 NODE_KINDS = {
8 "top_level" => { 9 "top_level" => {
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 0b6e65ee..f15be067 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -770,6 +770,20 @@ class NodesControllerTest < ActionController::TestCase
770 assert_select "form[action=?]", node_path(node), count: 1 770 assert_select "form[action=?]", node_path(node), count: 1
771 end 771 end
772 772
773 test "publishing a restricted node without the redaktion role flashes and does not publish" do
774 login_as :quentin
775 updates = Node.root.children.create!(:slug => "updates")
776 node = updates.children.create!(:slug => "controller-gated")
777 node.reload.draft.update!(:title => "Entwurf")
778
779 put :publish, params: { :id => node.id }
780
781 assert_redirected_to node_path(node)
782 assert_match I18n.t("activerecord.errors.models.node.attributes.base.not_permitted"),
783 flash[:error]
784 assert_nil node.reload.head
785 end
786
773test "show annotates history rows with their lifecycle" do 787test "show annotates history rows with their lifecycle" do
774 login_as :quentin 788 login_as :quentin
775 node = Node.root.children.create!(:slug => "history_annotation_test") 789 node = Node.root.children.create!(:slug => "history_annotation_test")
diff --git a/test/models/asset_destroy_test.rb b/test/models/asset_destroy_test.rb
index 5583f685..2f38d692 100644
--- a/test/models/asset_destroy_test.rb
+++ b/test/models/asset_destroy_test.rb
@@ -48,4 +48,17 @@ class AssetDestroyTest < ActiveSupport::TestCase
48 assert_equal "Doomed asset", action.metadata["asset_name"] 48 assert_equal "Doomed asset", action.metadata["asset_name"]
49 assert_nil action.action_participants.first.subject 49 assert_nil action.action_participants.first.subject
50 end 50 end
51
52 test "destroying an asset attached to a restricted node needs the redaktion role" do
53 editor = User.create!(:login => "asset_gate", :email => "ag@example.com",
54 :password => "secret", :password_confirmation => "secret")
55 updates = Node.root.children.create!(:slug => "updates")
56 node = updates.children.create!(:slug => "gated-attachment")
57 node.reload.attach_asset!(@asset, :user => nil)
58
59 error = assert_raises(ActiveRecord::RecordInvalid) { @asset.destroy_witnessed!(:user => editor) }
60 assert_includes error.message,
61 I18n.t("activerecord.errors.models.asset.attributes.base.not_permitted")
62 assert Asset.exists?(@asset.id)
63 end
51end 64end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index c1316ea6..f57f83bf 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -936,4 +936,72 @@ class NodeTest < ActiveSupport::TestCase
936 I18n.t("activerecord.errors.models.node.attributes.base.trash_the_trash") 936 I18n.t("activerecord.errors.models.node.attributes.base.trash_the_trash")
937 end 937 end
938 end 938 end
939
940 test "restricted? covers the root node, the restricted subtrees and their descendants" do
941 assert Node.root.restricted?, "the front page aggregates the feed"
942
943 updates = Node.root.children.create!(:slug => "updates")
944 assert updates.restricted?
945 year = updates.children.create!(:slug => "2026")
946 assert year.reload.restricted?
947 post = year.children.create!(:slug => "some-post")
948 assert post.reload.restricted?
949
950 disclosure = Node.root.children.create!(:slug => "disclosure")
951 assert disclosure.restricted?
952
953 plain = Node.root.children.create!(:slug => "club")
954 assert_not plain.restricted?
955 child = plain.children.create!(:slug => "erfas")
956 assert_not child.reload.restricted?
957 end
958
959 test "restricted? does not match a prefix that is merely a substring" do
960 decoy = Node.root.children.create!(:slug => "updatesomething")
961 assert_not decoy.restricted?
962 end
963
964 test "publishing a restricted node is refused without the redaktion role" do
965 editor = User.create!(:login => "guard_editor", :email => "gd@example.com",
966 :password => "secret", :password_confirmation => "secret")
967 updates = Node.root.children.create!(:slug => "updates")
968 node = updates.children.create!(:slug => "guarded-post")
969 node.reload.draft.update!(:title => "Entwurf")
970
971 error = assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) }
972 assert_includes error.message,
973 I18n.t("activerecord.errors.models.node.attributes.base.not_permitted")
974 assert_nil node.reload.head
975 end
976
977 test "publishing a restricted node succeeds with the redaktion role" do
978 red = User.create!(:login => "guard_red", :email => "gr2@example.com",
979 :password => "secret", :password_confirmation => "secret",
980 :roles => ["redaktion"])
981 updates = Node.root.children.create!(:slug => "updates")
982 node = updates.children.create!(:slug => "allowed-post")
983 node.reload.draft.update!(:title => "Entwurf")
984
985 node.publish_draft!(red)
986 assert_not_nil node.reload.head
987 end
988
989 test "publishing outside the restricted subtrees needs no role" do
990 editor = User.create!(:login => "guard_free", :email => "gf@example.com",
991 :password => "secret", :password_confirmation => "secret")
992 node = Node.root.children.create!(:slug => "guard-free-post")
993 node.reload.draft.update!(:title => "Entwurf")
994
995 node.publish_draft!(editor)
996 assert_not_nil node.reload.head
997 end
998
999 test "a nil user is a system context and bypasses the gate" do
1000 updates = Node.root.children.create!(:slug => "updates")
1001 node = updates.children.create!(:slug => "system-post")
1002 node.reload.draft.update!(:title => "Entwurf")
1003
1004 node.publish_draft!
1005 assert_not_nil node.reload.head
1006 end
939end 1007end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index feccce25..9942385c 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -126,6 +126,22 @@ class UserTest < ActiveSupport::TestCase
126 126
127 assert user.update(:email => "quentin@example.org") 127 assert user.update(:email => "quentin@example.org")
128 end 128 end
129
130 test "may_change_live? gates restricted subjects on the redaktion role" do
131 editor = User.create!(:login => "gate_editor", :email => "ge@example.com",
132 :password => "secret", :password_confirmation => "secret")
133 redaktion = User.create!(:login => "gate_red", :email => "gr@example.com",
134 :password => "secret", :password_confirmation => "secret",
135 :roles => ["redaktion"])
136
137 restricted = Node.root
138 plain = Node.root.children.create!(:slug => "gate_plain")
139
140 assert editor.may_change_live?(plain)
141 assert_not editor.may_change_live?(restricted)
142 assert redaktion.may_change_live?(plain)
143 assert redaktion.may_change_live?(restricted)
144 end
129 145
130protected 146protected
131 def create_user(options = {}) 147 def create_user(options = {})