diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 18:14:30 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 18:14:30 +0200 |
| commit | 464af1625349d557f688da9f845471ef8b80a5f9 (patch) | |
| tree | aab415abf115f20ccbcdfad49c27ce5d1bd61134 | |
| parent | 8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 (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.rb | 6 | ||||
| -rw-r--r-- | app/controllers/nodes_controller.rb | 3 | ||||
| -rw-r--r-- | app/models/asset.rb | 12 | ||||
| -rw-r--r-- | app/models/node.rb | 28 | ||||
| -rw-r--r-- | app/models/user.rb | 5 | ||||
| -rw-r--r-- | config/locales/de.yml | 5 | ||||
| -rw-r--r-- | config/locales/en.yml | 1 | ||||
| -rw-r--r-- | lib/ccc_conventions.rb | 1 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 14 | ||||
| -rw-r--r-- | test/models/asset_destroy_test.rb | 13 | ||||
| -rw-r--r-- | test/models/node_test.rb | 68 | ||||
| -rw-r--r-- | test/models/user_test.rb | 16 |
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 | |||
| 773 | test "show annotates history rows with their lifecycle" do | 787 | test "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 | ||
| 51 | end | 64 | end |
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 | ||
| 939 | end | 1007 | end |
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 | ||
| 130 | protected | 146 | protected |
| 131 | def create_user(options = {}) | 147 | def create_user(options = {}) |
