Skip to content
Open
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
13 changes: 13 additions & 0 deletions app/graphql/types/user_deletion_restriction_enum.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/graphql/types/user_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down
5 changes: 5 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/models/user_custom_attribute.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -32,4 +33,6 @@ class UserPolicy < BasePolicy
enable :read_email
enable :read_mfa_status
end

rule { deletion_restriction }.prevent :delete_user
end
15 changes: 15 additions & 0 deletions db/migrate/20260828172523_create_user_custom_attributes.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/schema_migrations/20260828172523
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
81ec8b9493eeef9381eefb578d29b53efea23a708c7c8fed626273134ed22793
29 changes: 29 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
10 changes: 10 additions & 0 deletions docs/graphql/enum/userdeletionrestriction.md
Original file line number Diff line number Diff line change
@@ -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. |
1 change: 1 addition & 0 deletions docs/graphql/object/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions extensions/cloud/app/models/cloud/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions extensions/cloud/spec/models/cloud/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions spec/factories/user_custom_attributes.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/graphql/types/user_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
sessions
identities
mfaStatus
deletionRestriction
userAbilities
createdAt
updatedAt
Expand Down
18 changes: 18 additions & 0 deletions spec/models/user_custom_attribute_spec.rb
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions spec/services/users/delete_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down