From c06612e3ed3ee8eb45b7142bde22b8a0406e46ea Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Fri, 28 Aug 2026 20:54:25 +0200 Subject: [PATCH 1/2] Implement user deletion restrictions --- .../types/user_deletion_restriction_enum.rb | 13 ++++++++ app/graphql/types/user_type.rb | 4 +++ app/models/user.rb | 5 +++ app/models/user_custom_attribute.rb | 11 +++++++ app/policies/user_policy.rb | 3 ++ ...828172523_create_user_custom_attributes.rb | 15 +++++++++ db/schema_migrations/20260828172523 | 1 + db/structure.sql | 29 +++++++++++++++++ docs/graphql/enum/userdeletionrestriction.md | 10 ++++++ docs/graphql/object/user.md | 1 + .../types/user_deletion_restriction_enum.rb | 15 +++++++++ extensions/cloud/app/models/cloud/user.rb | 8 +++++ .../cloud/spec/models/cloud/user_spec.rb | 31 ++++++++++++++++++ spec/factories/user_custom_attributes.rb | 9 ++++++ spec/graphql/types/user_type_spec.rb | 1 + spec/models/user_custom_attribute_spec.rb | 18 +++++++++++ spec/models/user_spec.rb | 32 +++++++++++++++++++ spec/services/users/delete_service_spec.rb | 2 ++ 18 files changed, 208 insertions(+) create mode 100644 app/graphql/types/user_deletion_restriction_enum.rb create mode 100644 app/models/user_custom_attribute.rb create mode 100644 db/migrate/20260828172523_create_user_custom_attributes.rb create mode 100644 db/schema_migrations/20260828172523 create mode 100644 docs/graphql/enum/userdeletionrestriction.md create mode 100644 extensions/cloud/app/graphql/cloud/types/user_deletion_restriction_enum.rb create mode 100644 spec/factories/user_custom_attributes.rb create mode 100644 spec/models/user_custom_attribute_spec.rb diff --git a/app/graphql/types/user_deletion_restriction_enum.rb b/app/graphql/types/user_deletion_restriction_enum.rb new file mode 100644 index 000000000..f46470764 --- /dev/null +++ b/app/graphql/types/user_deletion_restriction_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class UserDeletionRestrictionEnum < Types::BaseEnum + description 'The reason why a user cannot be deleted.' + + value 'LAST_ADMINISTRATOR', + 'The user is the last administrator of the instance.', + value: :last_administrator + end +end + +Types::UserDeletionRestrictionEnum.prepend_extensions diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 225ec575e..7f11c0588 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -51,6 +51,10 @@ class UserType < Types::BaseObject null: true, description: 'Multi-factor authentication status of this user' + field :deletion_restriction, Types::UserDeletionRestrictionEnum, + null: true, + description: 'The reason why this user cannot be deleted' + lookahead_field :namespace_memberships, base_scope: ->(object) { object.namespace_memberships }, conditional_lookaheads: { user: :user, namespace: { namespace: :namespace_members } } diff --git a/app/models/user.rb b/app/models/user.rb index 9169d7b29..f0f01ec84 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,6 +38,7 @@ class User < ApplicationRecord has_many :namespaces, through: :namespace_memberships, inverse_of: :users has_many :user_identities, inverse_of: :user + has_many :user_custom_attributes, inverse_of: :user has_one_attached :avatar @@ -75,6 +76,10 @@ def validate_mfa!(mfa) [mfa_passed, mfa_type] end + def deletion_restriction + :last_administrator unless self.class.where.not(id: id).exists?(admin: true) + end + generates_token_for :email_verification, expires_in: 15.minutes do email end diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb new file mode 100644 index 000000000..9f1d187c0 --- /dev/null +++ b/app/models/user_custom_attribute.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class UserCustomAttribute < ApplicationRecord + belongs_to :user, inverse_of: :user_custom_attributes + + validates :key, presence: true, + allow_blank: false, + length: { maximum: 255 }, + uniqueness: { scope: :user_id } + validates :value, presence: true +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ffc6993f7..75504fba4 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -5,6 +5,7 @@ class UserPolicy < BasePolicy condition(:user_is_admin) { user&.admin? || false } condition(:subject_is_regular) { subject.regular? } condition(:admin_status_visible) { ApplicationSetting.current[:admin_status_visible] } + condition(:deletion_restriction) { subject.deletion_restriction || false } rule { ~anonymous }.enable :read_user @@ -32,4 +33,6 @@ class UserPolicy < BasePolicy enable :read_email enable :read_mfa_status end + + rule { deletion_restriction }.prevent :delete_user end diff --git a/db/migrate/20260828172523_create_user_custom_attributes.rb b/db/migrate/20260828172523_create_user_custom_attributes.rb new file mode 100644 index 000000000..d78583b72 --- /dev/null +++ b/db/migrate/20260828172523_create_user_custom_attributes.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateUserCustomAttributes < Code0::ZeroTrack::Database::Migration[1.0] + def change + create_table :user_custom_attributes do |t| + t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false + t.text :key, null: false, limit: 255 + t.jsonb :value, null: false + + t.timestamps_with_timezone + + t.index %i[user_id key], unique: true + end + end +end diff --git a/db/schema_migrations/20260828172523 b/db/schema_migrations/20260828172523 new file mode 100644 index 000000000..f8cdb5252 --- /dev/null +++ b/db/schema_migrations/20260828172523 @@ -0,0 +1 @@ +81ec8b9493eeef9381eefb578d29b53efea23a708c7c8fed626273134ed22793 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6b8bc2c46..45ac66e16 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1257,6 +1257,25 @@ CREATE SEQUENCE translations_id_seq ALTER SEQUENCE translations_id_seq OWNED BY translations.id; +CREATE TABLE user_custom_attributes ( + id bigint NOT NULL, + user_id bigint NOT NULL, + key text NOT NULL, + value jsonb NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_8df49217f1 CHECK ((char_length(key) <= 255)) +); + +CREATE SEQUENCE user_custom_attributes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE user_custom_attributes_id_seq OWNED BY user_custom_attributes.id; + CREATE TABLE user_identities ( id bigint NOT NULL, user_id bigint NOT NULL, @@ -1430,6 +1449,8 @@ ALTER TABLE ONLY sub_flows ALTER COLUMN id SET DEFAULT nextval('sub_flows_id_seq ALTER TABLE ONLY translations ALTER COLUMN id SET DEFAULT nextval('translations_id_seq'::regclass); +ALTER TABLE ONLY user_custom_attributes ALTER COLUMN id SET DEFAULT nextval('user_custom_attributes_id_seq'::regclass); + ALTER TABLE ONLY user_identities ALTER COLUMN id SET DEFAULT nextval('user_identities_id_seq'::regclass); ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_sessions_id_seq'::regclass); @@ -1628,6 +1649,9 @@ ALTER TABLE ONLY sub_flows ALTER TABLE ONLY translations ADD CONSTRAINT translations_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_custom_attributes + ADD CONSTRAINT user_custom_attributes_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_identities ADD CONSTRAINT user_identities_pkey PRIMARY KEY (id); @@ -1865,6 +1889,8 @@ CREATE INDEX index_sub_flows_on_starting_node_id ON sub_flows USING btree (start CREATE INDEX index_translations_on_owner ON translations USING btree (owner_type, owner_id); +CREATE UNIQUE INDEX index_user_custom_attributes_on_user_id_and_key ON user_custom_attributes USING btree (user_id, key); + CREATE UNIQUE INDEX index_user_identities_on_provider_id_and_identifier ON user_identities USING btree (provider_id, identifier); CREATE INDEX index_user_identities_on_user_id ON user_identities USING btree (user_id); @@ -1948,6 +1974,9 @@ ALTER TABLE ONLY data_type_data_type_links ALTER TABLE p_execution_node_results ADD CONSTRAINT fk_rails_460ac90523 FOREIGN KEY (execution_result_id, created_at) REFERENCES p_execution_results(id, created_at) ON DELETE CASCADE; +ALTER TABLE ONLY user_custom_attributes + ADD CONSTRAINT fk_rails_47b91868a8 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY module_configurations ADD CONSTRAINT fk_rails_47f7323aca FOREIGN KEY (namespace_project_runtime_assignment_id) REFERENCES namespace_project_runtime_assignments(id) ON DELETE CASCADE; diff --git a/docs/graphql/enum/userdeletionrestriction.md b/docs/graphql/enum/userdeletionrestriction.md new file mode 100644 index 000000000..f0fb67b79 --- /dev/null +++ b/docs/graphql/enum/userdeletionrestriction.md @@ -0,0 +1,10 @@ +--- +title: UserDeletionRestriction +--- + +The reason why a user cannot be deleted. + +| Value | Description | +|-------|-------------| +| `ACTIVE_SUBSCRIPTION` | (Cloud only) The current user has an active subscription. | +| `LAST_ADMINISTRATOR` | The user is the last administrator of the instance. | diff --git a/docs/graphql/object/user.md b/docs/graphql/object/user.md index 13bd9ff00..b17ac7ca1 100644 --- a/docs/graphql/object/user.md +++ b/docs/graphql/object/user.md @@ -12,6 +12,7 @@ Represents a user | `avatarPath` | [`String`](../scalar/string.md) | The avatar if present of the user | | `blocked` | [`Boolean`](../scalar/boolean.md) | Whether the user is blocked from accessing the application | | `createdAt` | [`Time!`](../scalar/time.md) | Time when this User was created | +| `deletionRestriction` | [`UserDeletionRestriction`](../enum/userdeletionrestriction.md) | The reason why this user cannot be deleted | | `email` | [`String`](../scalar/string.md) | Email of the user | | `emailVerifiedAt` | [`Time`](../scalar/time.md) | Email verification date of the user if present | | `firstname` | [`String`](../scalar/string.md) | Firstname of the user | diff --git a/extensions/cloud/app/graphql/cloud/types/user_deletion_restriction_enum.rb b/extensions/cloud/app/graphql/cloud/types/user_deletion_restriction_enum.rb new file mode 100644 index 000000000..0d79ac9a1 --- /dev/null +++ b/extensions/cloud/app/graphql/cloud/types/user_deletion_restriction_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CLOUD + module Types + module UserDeletionRestrictionEnum + extend ActiveSupport::Concern + + prepended do + value 'ACTIVE_SUBSCRIPTION', + '(Cloud only) The current user has an active subscription.', + value: :active_subscription + end + end + end +end diff --git a/extensions/cloud/app/models/cloud/user.rb b/extensions/cloud/app/models/cloud/user.rb index b4ee30444..715d1948b 100644 --- a/extensions/cloud/app/models/cloud/user.rb +++ b/extensions/cloud/app/models/cloud/user.rb @@ -2,10 +2,18 @@ module CLOUD module User + include Sagittarius::Override extend ActiveSupport::Concern prepended do generates_token_for :crater_login, expires_in: 10.minutes end + + override :deletion_restriction + def deletion_restriction + return :active_subscription if user_custom_attributes.exists?(key: 'active_subscription') + + super + end end end diff --git a/extensions/cloud/spec/models/cloud/user_spec.rb b/extensions/cloud/spec/models/cloud/user_spec.rb index e8a6336cb..5867abf1e 100644 --- a/extensions/cloud/spec/models/cloud/user_spec.rb +++ b/extensions/cloud/spec/models/cloud/user_spec.rb @@ -18,4 +18,35 @@ expect(described_class.find_by_token_for(:crater_login, token)).to eq(user) end end + + describe '#deletion_restriction' do + subject(:user) { create(:user) } + + context 'when user has an active_subscription custom attribute' do + before { create(:user_custom_attribute, user: user, key: 'active_subscription', value: true) } + + it 'returns :active_subscription' do + expect(user.deletion_restriction).to eq(:active_subscription) + end + end + + context 'when user does not have an active_subscription custom attribute' do + before { create(:user, admin: true) } + + it 'falls through to the base implementation' do + expect(user.deletion_restriction).to be_nil + end + end + + context 'when user has other custom attributes but not active_subscription' do + before do + create(:user, admin: true) + create(:user_custom_attribute, user: user, key: 'some_other_key', value: 'some_value') + end + + it 'falls through to the base implementation' do + expect(user.deletion_restriction).to be_nil + end + end + end end diff --git a/spec/factories/user_custom_attributes.rb b/spec/factories/user_custom_attributes.rb new file mode 100644 index 000000000..8a403d584 --- /dev/null +++ b/spec/factories/user_custom_attributes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_custom_attribute do + user + key { 'test_key' } + value { 'test_value' } + end +end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index cab1df0c4..62ed82569 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -20,6 +20,7 @@ sessions identities mfaStatus + deletionRestriction userAbilities createdAt updatedAt diff --git a/spec/models/user_custom_attribute_spec.rb b/spec/models/user_custom_attribute_spec.rb new file mode 100644 index 000000000..0e62b2419 --- /dev/null +++ b/spec/models/user_custom_attribute_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserCustomAttribute do + subject { create(:user_custom_attribute) } + + describe 'associations' do + it { is_expected.to belong_to(:user).inverse_of(:user_custom_attributes).required } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_length_of(:key).is_at_most(255) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:user_id) } + it { is_expected.to validate_presence_of(:value) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 51c4cb123..aa9acbeb6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -10,6 +10,7 @@ it { is_expected.to have_many(:authored_audit_events).class_name('AuditEvent').inverse_of(:author) } it { is_expected.to have_many(:namespace_memberships).class_name('NamespaceMember').inverse_of(:user) } it { is_expected.to have_many(:namespaces).through(:namespace_memberships).inverse_of(:users) } + it { is_expected.to have_many(:user_custom_attributes).inverse_of(:user) } end describe 'validations' do @@ -24,4 +25,35 @@ it { is_expected.to validate_length_of(:firstname).is_at_most(50) } it { is_expected.to validate_length_of(:lastname).is_at_most(50) } end + + describe '#deletion_restriction' do + subject(:user) { create(:user) } + + context 'when user is the last administrator' do + before { user.update!(admin: true) } + + it 'returns :last_administrator' do + expect(user.deletion_restriction).to eq(:last_administrator) + end + end + + context 'when another administrator exists' do + before do + user.update!(admin: true) + create(:user, :admin) + end + + it 'returns nil' do + expect(user.deletion_restriction).to be_nil + end + end + + context 'when user is not an administrator' do + before { create(:user, :admin) } + + it 'returns nil' do + expect(user.deletion_restriction).to be_nil + end + end + end end diff --git a/spec/services/users/delete_service_spec.rb b/spec/services/users/delete_service_spec.rb index 2a50f98f5..15f0e7aff 100644 --- a/spec/services/users/delete_service_spec.rb +++ b/spec/services/users/delete_service_spec.rb @@ -66,6 +66,8 @@ context 'when deleting the current user' do let(:user) { current_user } + before { create(:user, :admin) } + it 'creates the deletion audit event with the ghost user as author' do expect(service_response).to be_success From 3dab0aa5e62e5967ca7dd94d898277ad08d3f62f Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Fri, 28 Aug 2026 20:58:39 +0200 Subject: [PATCH 2/2] Fix SimpleCov deprecation warning --- spec/spec_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c527abecb..33f8a526f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,13 +5,13 @@ SimpleCov.start do coverage_dir 'tmp/coverage' - add_filter 'spec' + skip 'spec' %w[controllers finders graphql grpc jobs models policies services].each do |type| - add_group type.capitalize, "app/#{type}" + group type.capitalize, "app/#{type}" end - add_group 'Tooling', 'tooling' + group 'Tooling', 'tooling' formatter SimpleCov::Formatter::MultiFormatter.new( [