Commit da6b9e51 authored by Toon Claes's avatar Toon Claes

Merge branch 'add-compliance-framework-setting-to-projects' into 'master'

Add compliance framework setting to projects

See merge request gitlab-org/gitlab!28182
parents 958021a7 a68d1255
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
= f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
%p.form-text.text-muted= _('Separate topics with commas.') %p.form-text.text-muted= _('Separate topics with commas.')
= render_if_exists 'compliance_management/compliance_framework/project_settings', f: f
.row .row
.form-group.col-md-9 .form-group.col-md-9
= f.label :description, _('Project description (optional)'), class: 'label-bold' = f.label :description, _('Project description (optional)'), class: 'label-bold'
......
# frozen_string_literal: true
class AddProjectComplianceFrameworkSettingsTable < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
create_table :project_compliance_framework_settings, id: false do |t|
t.references :project, primary_key: true, null: false, index: true, foreign_key: { on_delete: :cascade }
t.integer :framework, null: false, limit: 2
end
end
end
def down
with_lock_retries do
drop_table :project_compliance_framework_settings
end
end
end
...@@ -4722,6 +4722,20 @@ CREATE SEQUENCE public.project_ci_cd_settings_id_seq ...@@ -4722,6 +4722,20 @@ CREATE SEQUENCE public.project_ci_cd_settings_id_seq
ALTER SEQUENCE public.project_ci_cd_settings_id_seq OWNED BY public.project_ci_cd_settings.id; ALTER SEQUENCE public.project_ci_cd_settings_id_seq OWNED BY public.project_ci_cd_settings.id;
CREATE TABLE public.project_compliance_framework_settings (
project_id bigint NOT NULL,
framework smallint NOT NULL
);
CREATE SEQUENCE public.project_compliance_framework_settings_project_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.project_compliance_framework_settings_project_id_seq OWNED BY public.project_compliance_framework_settings.project_id;
CREATE TABLE public.project_custom_attributes ( CREATE TABLE public.project_custom_attributes (
id integer NOT NULL, id integer NOT NULL,
created_at timestamp with time zone NOT NULL, created_at timestamp with time zone NOT NULL,
...@@ -7315,6 +7329,8 @@ ALTER TABLE ONLY public.project_auto_devops ALTER COLUMN id SET DEFAULT nextval( ...@@ -7315,6 +7329,8 @@ ALTER TABLE ONLY public.project_auto_devops ALTER COLUMN id SET DEFAULT nextval(
ALTER TABLE ONLY public.project_ci_cd_settings ALTER COLUMN id SET DEFAULT nextval('public.project_ci_cd_settings_id_seq'::regclass); ALTER TABLE ONLY public.project_ci_cd_settings ALTER COLUMN id SET DEFAULT nextval('public.project_ci_cd_settings_id_seq'::regclass);
ALTER TABLE ONLY public.project_compliance_framework_settings ALTER COLUMN project_id SET DEFAULT nextval('public.project_compliance_framework_settings_project_id_seq'::regclass);
ALTER TABLE ONLY public.project_custom_attributes ALTER COLUMN id SET DEFAULT nextval('public.project_custom_attributes_id_seq'::regclass); ALTER TABLE ONLY public.project_custom_attributes ALTER COLUMN id SET DEFAULT nextval('public.project_custom_attributes_id_seq'::regclass);
ALTER TABLE ONLY public.project_daily_statistics ALTER COLUMN id SET DEFAULT nextval('public.project_daily_statistics_id_seq'::regclass); ALTER TABLE ONLY public.project_daily_statistics ALTER COLUMN id SET DEFAULT nextval('public.project_daily_statistics_id_seq'::regclass);
...@@ -8144,6 +8160,9 @@ ALTER TABLE ONLY public.project_auto_devops ...@@ -8144,6 +8160,9 @@ ALTER TABLE ONLY public.project_auto_devops
ALTER TABLE ONLY public.project_ci_cd_settings ALTER TABLE ONLY public.project_ci_cd_settings
ADD CONSTRAINT project_ci_cd_settings_pkey PRIMARY KEY (id); ADD CONSTRAINT project_ci_cd_settings_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.project_compliance_framework_settings
ADD CONSTRAINT project_compliance_framework_settings_pkey PRIMARY KEY (project_id);
ALTER TABLE ONLY public.project_custom_attributes ALTER TABLE ONLY public.project_custom_attributes
ADD CONSTRAINT project_custom_attributes_pkey PRIMARY KEY (id); ADD CONSTRAINT project_custom_attributes_pkey PRIMARY KEY (id);
...@@ -9736,6 +9755,8 @@ CREATE UNIQUE INDEX index_project_auto_devops_on_project_id ON public.project_au ...@@ -9736,6 +9755,8 @@ CREATE UNIQUE INDEX index_project_auto_devops_on_project_id ON public.project_au
CREATE UNIQUE INDEX index_project_ci_cd_settings_on_project_id ON public.project_ci_cd_settings USING btree (project_id); CREATE UNIQUE INDEX index_project_ci_cd_settings_on_project_id ON public.project_ci_cd_settings USING btree (project_id);
CREATE INDEX index_project_compliance_framework_settings_on_project_id ON public.project_compliance_framework_settings USING btree (project_id);
CREATE INDEX index_project_custom_attributes_on_key_and_value ON public.project_custom_attributes USING btree (key, value); CREATE INDEX index_project_custom_attributes_on_key_and_value ON public.project_custom_attributes USING btree (key, value);
CREATE UNIQUE INDEX index_project_custom_attributes_on_project_id_and_key ON public.project_custom_attributes USING btree (project_id, key); CREATE UNIQUE INDEX index_project_custom_attributes_on_project_id_and_key ON public.project_custom_attributes USING btree (project_id, key);
...@@ -11391,6 +11412,9 @@ ALTER TABLE ONLY public.prometheus_alerts ...@@ -11391,6 +11412,9 @@ ALTER TABLE ONLY public.prometheus_alerts
ALTER TABLE ONLY public.term_agreements ALTER TABLE ONLY public.term_agreements
ADD CONSTRAINT fk_rails_6ea6520e4a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_6ea6520e4a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.project_compliance_framework_settings
ADD CONSTRAINT fk_rails_6f5294f16c FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.users_security_dashboard_projects ALTER TABLE ONLY public.users_security_dashboard_projects
ADD CONSTRAINT fk_rails_6f6cf8e66e FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_6f6cf8e66e FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
...@@ -13002,6 +13026,7 @@ COPY "schema_migrations" (version) FROM STDIN; ...@@ -13002,6 +13026,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200330121000 20200330121000
20200330123739 20200330123739
20200330132913 20200330132913
20200331132103
20200331195952 20200331195952
20200331220930 20200331220930
20200402123926 20200402123926
......
...@@ -89,6 +89,8 @@ module EE ...@@ -89,6 +89,8 @@ module EE
attrs += merge_request_rules_params attrs += merge_request_rules_params
attrs += compliance_framework_params
if allow_mirror_params? if allow_mirror_params?
attrs + mirror_params attrs + mirror_params
else else
...@@ -134,6 +136,12 @@ module EE ...@@ -134,6 +136,12 @@ module EE
project&.feature_available?(:merge_pipelines) project&.feature_available?(:merge_pipelines)
end end
def compliance_framework_params
return [] unless current_user.can?(:admin_compliance_framework, project)
[compliance_framework_setting_attributes: [:framework]]
end
def log_audit_event(message:) def log_audit_event(message:)
AuditEvents::CustomAuditEventService.new( AuditEvents::CustomAuditEventService.new(
current_user, current_user,
......
# frozen_string_literal: true
module ComplianceManagement
module ComplianceFramework
module ProjectSettingsHelper
def compliance_framework_options
option_values = compliance_framework_option_values
ProjectSettings.frameworks.map { |k, _v| [option_values.fetch(k.to_sym), k] }
end
def compliance_framework_option_values
{
gdpr: s_('ComplianceFramework|GDPR - General Data Protection Regulation'),
hipaa: s_('ComplianceFramework|HIPAA - Health Insurance Portability and Accountability Act'),
pci_dss: s_('ComplianceFramework|PCI-DSS - Payment Card Industry-Data Security Standard'),
soc_2: s_('ComplianceFramework|SOC 2 - Service Organization Control 2'),
sox: s_('ComplianceFramework|SOX - Sarbanes-Oxley')
}.freeze
end
end
end
end
# frozen_string_literal: true
module ComplianceManagement
module ComplianceFramework
class ProjectSettings < ApplicationRecord
self.table_name = 'project_compliance_framework_settings'
self.primary_key = :project_id
belongs_to :project
enum framework: {
gdpr: 1, # General Data Protection Regulation
hipaa: 2, # Health Insurance Portability and Accountability Act
pci_dss: 3, # Payment Card Industry-Data Security Standard
soc_2: 4, # Service Organization Control 2
sox: 5 # Sarbanes-Oxley
}
validates :project, presence: true
validates :framework, uniqueness: { scope: [:project_id] }
validates :framework, inclusion: { in: self.frameworks.keys }
end
end
end
...@@ -48,6 +48,7 @@ module EE ...@@ -48,6 +48,7 @@ module EE
has_one :tracing_setting, class_name: 'ProjectTracingSetting' has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :feature_usage, class_name: 'ProjectFeatureUsage' has_one :feature_usage, class_name: 'ProjectFeatureUsage'
has_one :status_page_setting, inverse_of: :project has_one :status_page_setting, inverse_of: :project
has_one :compliance_framework_setting, class_name: 'ComplianceManagement::ComplianceFramework::ProjectSettings', inverse_of: :project
has_many :reviews, inverse_of: :project has_many :reviews, inverse_of: :project
has_many :approvers, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :approvers, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
...@@ -180,6 +181,7 @@ module EE ...@@ -180,6 +181,7 @@ module EE
accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :status_page_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :status_page_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :compliance_framework_setting, update_only: true, allow_destroy: true
alias_attribute :fallback_approvals_required, :approvals_before_merge alias_attribute :fallback_approvals_required, :approvals_before_merge
end end
......
...@@ -107,6 +107,7 @@ class License < ApplicationRecord ...@@ -107,6 +107,7 @@ class License < ApplicationRecord
EEU_FEATURES = EEP_FEATURES + %i[ EEU_FEATURES = EEP_FEATURES + %i[
cluster_health cluster_health
compliance_framework
container_scanning container_scanning
credentials_inventory credentials_inventory
dast dast
......
...@@ -36,6 +36,8 @@ module EE ...@@ -36,6 +36,8 @@ module EE
with_scope :subject with_scope :subject
condition(:requirements_available) { @subject.feature_available?(:requirements) } condition(:requirements_available) { @subject.feature_available?(:requirements) }
condition(:compliance_framework_available) { @subject.feature_available?(:compliance_framework, @user) }
with_scope :global with_scope :global
condition(:is_development) { Rails.env.development? } condition(:is_development) { Rails.env.development? }
...@@ -364,6 +366,8 @@ module EE ...@@ -364,6 +366,8 @@ module EE
rule { requirements_available & owner }.enable :destroy_requirement rule { requirements_available & owner }.enable :destroy_requirement
rule { compliance_framework_available & can?(:admin_project) }.enable :admin_compliance_framework
rule { status_page_available & can?(:developer_access) }.enable :publish_status_page rule { status_page_available & can?(:developer_access) }.enable :publish_status_page
end end
......
...@@ -18,6 +18,8 @@ module EE ...@@ -18,6 +18,8 @@ module EE
return project return project
end end
compliance_framework_setting
result = super do result = super do
# Repository size limit comes as MB from the view # Repository size limit comes as MB from the view
project.repository_size_limit = ::Gitlab::Utils.try_megabytes_to_bytes(limit) if limit project.repository_size_limit = ::Gitlab::Utils.try_megabytes_to_bytes(limit) if limit
...@@ -48,6 +50,18 @@ module EE ...@@ -48,6 +50,18 @@ module EE
mirror_user_id == project.mirror_user&.id mirror_user_id == project.mirror_user&.id
end end
def compliance_framework_setting
settings = params[:compliance_framework_setting_attributes]
return unless settings.present?
unless can?(current_user, :admin_compliance_framework, project)
params.delete(:compliance_framework_setting_attributes)
return
end
settings.merge!(_destroy: settings[:framework].blank?)
end
def log_audit_events def log_audit_events
EE::Audit::ProjectChangesAuditor.new(current_user, project).execute EE::Audit::ProjectChangesAuditor.new(current_user, project).execute
end end
......
- return unless current_user.can?(:admin_compliance_framework, @project)
.row
.form-group.col-md-9.mb-5
= f.fields_for :compliance_framework_setting, ComplianceManagement::ComplianceFramework::ProjectSettings.new do |cf|
= cf.label :framework, _('Compliance framework (optional)'), class: 'label-bold'
%p.text-secondary= _('Select required regulatory standard')
= cf.select :framework, options_for_select(compliance_framework_options, @project.compliance_framework_setting&.framework), { selected: '', disabled: '', prompt: _('Choose your framework'), include_blank: _('None') }, class: 'form-control'
---
title: Add a compliance framework setting to project
merge_request: 28182
author:
type: added
...@@ -376,6 +376,47 @@ describe ProjectsController do ...@@ -376,6 +376,47 @@ describe ProjectsController do
end end
end end
end end
context 'compliance framework settings' do
let(:framework) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys.sample }
let(:params) { { compliance_framework_setting_attributes: { framework: framework } } }
context 'when unlicensed' do
before do
stub_licensed_features(compliance_framework: false)
end
it 'ignores any compliance framework params' do
put :update,
params: {
namespace_id: project.namespace,
id: project,
project: params
}
project.reload
expect(project.compliance_framework_setting).to be_nil
end
end
context 'when licensed' do
before do
stub_licensed_features(compliance_framework: true)
end
it 'sets the compliance framework' do
put :update,
params: {
namespace_id: project.namespace,
id: project,
project: params
}
project.reload
expect(project.compliance_framework_setting.framework).to eq(framework)
end
end
end
end end
describe '#download_export' do describe '#download_export' do
......
# frozen_string_literal: true
FactoryBot.define do
factory :compliance_framework_project_setting, class: 'ComplianceManagement::ComplianceFramework::ProjectSettings' do
project
framework { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys.sample }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ComplianceManagement::ComplianceFramework::ProjectSettingsHelper do
let(:frameworks) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys }
let(:descriptions) { helper.compliance_framework_option_values }
describe '#compliance_framework_options' do
it 'has all the descriptions' do
expect(helper.compliance_framework_options.map(&:first)).to eq(descriptions.map(&:last))
end
it 'has all the frameworks' do
expect(helper.compliance_framework_options.map(&:last)).to eq(frameworks)
end
end
describe '#compliance_framework_option_values' do
it 'returns a hash' do
expect(helper.compliance_framework_option_values).to be_a_kind_of(Hash)
end
it 'is the same length as frameworks' do
expect(helper.compliance_framework_option_values.length).to equal(frameworks.length)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ComplianceManagement::ComplianceFramework::ProjectSettings do
let(:known_frameworks) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys }
subject { build :compliance_framework_project_setting }
describe 'Associations' do
it 'belongs to project' do
expect(subject).to belong_to(:project)
end
end
describe 'Validations' do
it 'confirms the presence of project' do
expect(subject).to validate_presence_of(:project)
end
it 'confirms that the framework is unique for the project' do
expect(subject).to validate_uniqueness_of(:framework).scoped_to(:project_id).ignoring_case_sensitivity
end
it 'allows all known frameworks' do
expect(subject).to allow_values(*known_frameworks).for(:framework)
end
it 'invalidates an unknown framework' do
expect { build :compliance_framework_project_setting, framework: 'ABCDEFGH' }.to raise_error(ArgumentError).with_message(/is not a valid framework/)
end
end
end
...@@ -23,6 +23,7 @@ describe Project do ...@@ -23,6 +23,7 @@ describe Project do
it { is_expected.to have_one(:import_state).class_name('ProjectImportState') } it { is_expected.to have_one(:import_state).class_name('ProjectImportState') }
it { is_expected.to have_one(:repository_state).class_name('ProjectRepositoryState').inverse_of(:project) } it { is_expected.to have_one(:repository_state).class_name('ProjectRepositoryState').inverse_of(:project) }
it { is_expected.to have_one(:status_page_setting).class_name('StatusPageSetting') } it { is_expected.to have_one(:status_page_setting).class_name('StatusPageSetting') }
it { is_expected.to have_one(:compliance_framework_setting).class_name('ComplianceManagement::ComplianceFramework::ProjectSettings') }
it { is_expected.to have_many(:reviews).inverse_of(:project) } it { is_expected.to have_many(:reviews).inverse_of(:project) }
it { is_expected.to have_many(:path_locks) } it { is_expected.to have_many(:path_locks) }
......
...@@ -1281,4 +1281,37 @@ describe ProjectPolicy do ...@@ -1281,4 +1281,37 @@ describe ProjectPolicy do
it_behaves_like 'resource with requirement permissions' do it_behaves_like 'resource with requirement permissions' do
let(:resource) { project } let(:resource) { project }
end end
describe ':compliance_framework_available' do
using RSpec::Parameterized::TableSyntax
let(:policy) { :admin_compliance_framework }
where(:role, :feature_enabled, :allowed) do
:guest | false | false
:guest | true | false
:reporter | false | false
:reporter | true | false
:developer | false | false
:developer | true | false
:maintainer | false | false
:maintainer | true | true
:owner | false | false
:owner | true | true
:admin | false | false
:admin | true | true
end
with_them do
let(:current_user) { public_send(role) }
before do
stub_licensed_features(compliance_framework: feature_enabled)
end
it do
is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy))
end
end
end
end end
...@@ -235,6 +235,70 @@ describe Projects::UpdateService, '#execute' do ...@@ -235,6 +235,70 @@ describe Projects::UpdateService, '#execute' do
end end
end end
context 'when compliance frameworks is set' do
let(:project_setting) { create(:compliance_framework_project_setting) }
before do
stub_licensed_features(compliance_framework: true)
project.update!(compliance_framework_setting: project_setting)
end
context 'when framework is not blank' do
let(:framework) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys.without(project_setting.framework).sample }
let(:opts) { { compliance_framework_setting_attributes: { framework: framework } } }
it 'saves the framework' do
update_project(project, user, opts)
expect(project.reload.compliance_framework_setting.framework).to eq(framework)
end
end
context 'when framework is blank' do
let(:opts) { { compliance_framework_setting_attributes: { framework: '' } } }
it 'removes the framework record' do
update_project(project, user, opts)
expect(project.reload.compliance_framework_setting).to be_nil
end
end
end
context 'when compliance framework feature is disabled' do
before do
stub_licensed_features(compliance_framework: false)
end
context 'the project had the feature before' do
let(:project_setting) { create(:compliance_framework_project_setting) }
before do
project.update!(compliance_framework_setting: project_setting)
end
let(:framework) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys.without(project_setting.framework).sample }
let(:opts) { { compliance_framework_setting_attributes: { framework: framework } } }
it 'does not save the new framework and retains the old setting' do
update_project(project, user, opts)
expect(project.reload.compliance_framework_setting.framework).to eq(project_setting.framework)
end
end
context 'the project never had the feature' do
let(:framework) { ComplianceManagement::ComplianceFramework::ProjectSettings.frameworks.keys.sample }
let(:opts) { { compliance_framework_setting_attributes: { framework: framework } } }
it 'does not save the framework' do
update_project(project, user, opts)
expect(project.reload.compliance_framework_setting).to be_nil
end
end
end
it 'returns an error result when record cannot be updated' do it 'returns an error result when record cannot be updated' do
admin = create(:admin) admin = create(:admin)
......
...@@ -129,6 +129,7 @@ module API ...@@ -129,6 +129,7 @@ module API
:avatar, :avatar,
:suggestion_commit_message, :suggestion_commit_message,
:repository_storage, :repository_storage,
:compliance_framework_setting,
# TODO: remove in API v5, replaced by *_access_level # TODO: remove in API v5, replaced by *_access_level
:issues_enabled, :issues_enabled,
......
...@@ -159,6 +159,7 @@ excluded_attributes: ...@@ -159,6 +159,7 @@ excluded_attributes:
- :max_artifacts_size - :max_artifacts_size
- :marked_for_deletion_at - :marked_for_deletion_at
- :marked_for_deletion_by_user_id - :marked_for_deletion_by_user_id
- :compliance_framework_setting
namespaces: namespaces:
- :runners_token - :runners_token
- :runners_token_encrypted - :runners_token_encrypted
......
...@@ -3833,6 +3833,9 @@ msgstr "" ...@@ -3833,6 +3833,9 @@ msgstr ""
msgid "Choose which status most accurately reflects the current state of this issue:" msgid "Choose which status most accurately reflects the current state of this issue:"
msgstr "" msgstr ""
msgid "Choose your framework"
msgstr ""
msgid "CiStatusLabel|canceled" msgid "CiStatusLabel|canceled"
msgstr "" msgstr ""
...@@ -5239,6 +5242,24 @@ msgstr "" ...@@ -5239,6 +5242,24 @@ msgstr ""
msgid "Compliance Dashboard" msgid "Compliance Dashboard"
msgstr "" msgstr ""
msgid "Compliance framework (optional)"
msgstr ""
msgid "ComplianceFramework|GDPR - General Data Protection Regulation"
msgstr ""
msgid "ComplianceFramework|HIPAA - Health Insurance Portability and Accountability Act"
msgstr ""
msgid "ComplianceFramework|PCI-DSS - Payment Card Industry-Data Security Standard"
msgstr ""
msgid "ComplianceFramework|SOC 2 - Service Organization Control 2"
msgstr ""
msgid "ComplianceFramework|SOX - Sarbanes-Oxley"
msgstr ""
msgid "Confidence: %{confidence}" msgid "Confidence: %{confidence}"
msgstr "" msgstr ""
...@@ -17970,6 +17991,9 @@ msgstr "" ...@@ -17970,6 +17991,9 @@ msgstr ""
msgid "Select projects you want to import." msgid "Select projects you want to import."
msgstr "" msgstr ""
msgid "Select required regulatory standard"
msgstr ""
msgid "Select shards to replicate" msgid "Select shards to replicate"
msgstr "" msgstr ""
......
...@@ -477,6 +477,7 @@ project: ...@@ -477,6 +477,7 @@ project:
- export_jobs - export_jobs
- daily_report_results - daily_report_results
- jira_imports - jira_imports
- compliance_framework_setting
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment