Commit 8ffcc8db authored by Douwe Maan's avatar Douwe Maan

Merge branch '2132-protect-tags-per-user-or-group' into 'master'

Per user/group access levels for Protected Tags

Closes #2132

See merge request !1629
parents a89ebdb1 3e596225
export const ACCESS_LEVELS = {
CREATE: 'create_access_levels',
};
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
};
export const ACCESS_LEVEL_NONE = 0;
/* global Flash */
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
import ProtectedTagDropdown from './protected_tag_dropdown'; import ProtectedTagDropdown from './protected_tag_dropdown';
...@@ -5,6 +8,12 @@ export default class ProtectedTagCreate { ...@@ -5,6 +8,12 @@ export default class ProtectedTagCreate {
constructor() { constructor() {
this.$form = $('.js-new-protected-tag'); this.$form = $('.js-new-protected-tag');
this.buildDropdowns(); this.buildDropdowns();
this.$branchTag = this.$form.find('input[name="protected_tag[name]"]');
this.bindEvents();
}
bindEvents() {
this.$form.on('submit', this.onFormSubmit.bind(this));
} }
buildDropdowns() { buildDropdowns() {
...@@ -14,15 +23,13 @@ export default class ProtectedTagCreate { ...@@ -14,15 +23,13 @@ export default class ProtectedTagCreate {
this.onSelectCallback = this.onSelect.bind(this); this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Create dropdown // Allowed to Create dropdown
this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ this[`${ACCESS_LEVELS.CREATE}_dropdown`] = new ProtectedTagAccessDropdown({
$dropdown: $allowedToCreateDropdown, $dropdown: $allowedToCreateDropdown,
data: gon.create_access_levels, accessLevelsData: gon.create_access_levels,
onSelect: this.onSelectCallback, onSelect: this.onSelectCallback,
accessLevel: ACCESS_LEVELS.CREATE,
}); });
// Select default
$allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
// Protected tag dropdown // Protected tag dropdown
this.protectedTagDropdown = new ProtectedTagDropdown({ this.protectedTagDropdown = new ProtectedTagDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'), $dropdown: this.$form.find('.js-protected-tag-select'),
...@@ -30,12 +37,60 @@ export default class ProtectedTagCreate { ...@@ -30,12 +37,60 @@ export default class ProtectedTagCreate {
}); });
} }
// This will run after clicked callback // Enable submit button after selecting an option
onSelect() { onSelect() {
// Enable submit button const $allowedToCreate = this[`${ACCESS_LEVELS.CREATE}_dropdown`].getSelectedItems();
const $tagInput = this.$form.find('input[name="protected_tag[name]"]'); const toggle = !(this.$form.find('input[name="protected_tag[name]"]').val() && $allowedToCreate.length);
const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
this.$form.find('input[type="submit"]').attr('disabled', toggle);
}
getFormData() {
const formData = {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
protected_tag: {
name: this.$form.find('input[name="protected_tag[name]"]').val(),
},
};
Object.keys(ACCESS_LEVELS).forEach((level) => {
const accessLevel = ACCESS_LEVELS[level];
const selectedItems = this[`${ACCESS_LEVELS.CREATE}_dropdown`].getSelectedItems();
const levelAttributes = [];
selectedItems.forEach((item) => {
if (item.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: item.user_id,
});
} else if (item.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: item.access_level,
});
} else if (item.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: item.group_id,
});
}
});
formData.protected_tag[`${accessLevel}_attributes`] = levelAttributes;
});
return formData;
}
onFormSubmit(e) {
e.preventDefault();
this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length)); $.ajax({
url: this.$form.attr('action'),
method: this.$form.attr('method'),
data: this.getFormData(),
})
.success(() => {
location.reload();
})
.fail(() => new Flash('Failed to protect the tag'));
} }
} }
...@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown { ...@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown; this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent(); this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown(); this.buildDropdown();
this.bindEvents(); this.bindEvents();
...@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown { ...@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
}; };
this.$dropdownContainer this.$dropdownContainer
.find('.create-new-protected-tag code') .find('.js-create-new-protected-tag code')
.text(tagName); .text(tagName);
} }
......
/* eslint-disable no-new */ /* eslint-disable no-new */
/* global Flash */ /* global Flash */
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
export default class ProtectedTagEdit { export default class ProtectedTagEdit {
constructor(options) { constructor(options) {
this.hasChanges = false;
this.$wrap = options.$wrap; this.$wrap = options.$wrap;
this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create'); this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create');
this.onSelectCallback = this.onSelect.bind(this);
this.$allowedToCreateDropdownContainer = this.$allowedToCreateDropdownButton.closest('.create_access_levels-container');
this.buildDropdowns(); this.buildDropdowns();
} }
buildDropdowns() { buildDropdowns() {
// Allowed to create dropdown // Allowed to create dropdown
this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ this[`${ACCESS_LEVELS.CREATE}_dropdown`] = new ProtectedTagAccessDropdown({
accessLevel: ACCESS_LEVELS.CREATE,
accessLevelsData: gon.create_access_levels,
$dropdown: this.$allowedToCreateDropdownButton, $dropdown: this.$allowedToCreateDropdownButton,
data: gon.create_access_levels, onSelect: this.onSelectOption.bind(this),
onSelect: this.onSelectCallback, onHide: this.onDropdownHide.bind(this),
}); });
} }
onSelect() { onSelectOption() {
const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`); this.hasChanges = true;
}
onDropdownHide() {
if (!this.hasChanges) {
return;
}
// Do not update if one dropdown has not selected any option this.hasChanges = true;
if (!$allowedToCreateInput.length) return; this.updatePermissions();
}
this.$allowedToCreateDropdownButton.disable(); updatePermissions() {
const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => {
/* eslint-disable no-param-reassign */
const accessLevelName = ACCESS_LEVELS[level];
const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName);
acc[`${accessLevelName}_attributes`] = inputData;
$.ajax({ return acc;
}, {});
return $.ajax({
type: 'POST', type: 'POST',
url: this.$wrap.data('url'), url: this.$wrap.data('url'),
dataType: 'json', dataType: 'json',
data: { data: {
_method: 'PATCH', _method: 'PATCH',
protected_tag: { protected_tag: formData,
create_access_levels_attributes: [{ },
id: this.$allowedToCreateDropdownButton.data('access-level-id'), success: (response) => {
access_level: $allowedToCreateInput.val(), this.hasChanges = false;
}],
}, Object.keys(ACCESS_LEVELS).forEach((level) => {
const accessLevelName = ACCESS_LEVELS[level];
// The data coming from server will be the new persisted *state* for each dropdown
this.setSelectedItemsToDropdown(response[accessLevelName], `${accessLevelName}_dropdown`);
});
}, },
error() { error() {
new Flash('Failed to update tag!', null, $('.js-protected-tags-list')); $.scrollTo(0);
new Flash('Failed to update tag!');
}, },
}).always(() => { }).always(() => {
this.$allowedToCreateDropdownButton.enable(); this.$allowedToCreateDropdownButton.enable();
}); });
} }
setSelectedItemsToDropdown(items = [], dropdownName) {
const itemsToAdd = items.map((currentItem) => {
if (currentItem.user_id) {
// Do this only for users for now
// get the current data for selected items
const selectedItems = this[dropdownName].getSelectedItems();
const currentSelectedItem = _.findWhere(selectedItems, { user_id: currentItem.user_id });
return {
id: currentItem.id,
user_id: currentItem.user_id,
type: LEVEL_TYPES.USER,
persisted: true,
name: currentSelectedItem.name,
username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url,
};
} else if (currentItem.group_id) {
return {
id: currentItem.id,
group_id: currentItem.group_id,
type: LEVEL_TYPES.GROUP,
persisted: true,
};
}
return {
id: currentItem.id,
access_level: currentItem.access_level,
type: LEVEL_TYPES.ROLE,
persisted: true,
};
});
this[dropdownName].setSelectedItems(itemsToAdd);
}
} }
...@@ -683,18 +683,19 @@ pre.light-well { ...@@ -683,18 +683,19 @@ pre.light-well {
} }
} }
.new_protected_branch { a.allowed-to-merge,
a.allowed-to-push {
cursor: pointer;
}
.new-protected-branch,
.new-protected-tag {
label { label {
margin-top: 6px; margin-top: 6px;
font-weight: normal; font-weight: normal;
} }
} }
a.allowed-to-merge,
a.allowed-to-push {
cursor: pointer;
}
.protected-branch-push-access-list, .protected-branch-push-access-list,
.protected-branch-merge-access-list { .protected-branch-merge-access-list {
a { a {
...@@ -702,7 +703,8 @@ a.allowed-to-push { ...@@ -702,7 +703,8 @@ a.allowed-to-push {
} }
} }
.create-new-protected-branch-button { .create-new-protected-branch-button,
.create-new-protected-tag-button {
@include dropdown-link; @include dropdown-link;
width: 100%; width: 100%;
......
...@@ -23,7 +23,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController ...@@ -23,7 +23,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params def protected_ref_params
params.require(:protected_branch).permit(:name, params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id], merge_access_levels_attributes: access_level_attributes,
push_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id]) push_access_levels_attributes: access_level_attributes)
end end
end end
...@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController ...@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok } format.js { head :ok }
end end
end end
protected
def access_level_attributes
%i(access_level id user_id _destroy group_id)
end
end end
class Projects::ProtectedTags::ApplicationController < Projects::ApplicationController
protected
def load_protected_tag
@protected_tag = @project.protected_tags.find(params[:protected_tag_id])
end
end
module Projects
module ProtectedTags
class CreateAccessLevelsController < ProtectedTags::ApplicationController
before_action :load_protected_tag, only: [:destroy]
def destroy
@create_access_level = @protected_tag.create_access_levels.find(params[:id])
@create_access_level.destroy
redirect_to namespace_project_protected_tag_path(@project.namespace, @project, @protected_tag),
notice: "Successfully deleted. #{@create_access_level.humanize} will not be able to create this protected tag."
end
end
end
end
...@@ -22,6 +22,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController ...@@ -22,6 +22,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
end end
def protected_ref_params def protected_ref_params
params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id]) params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end end
end end
...@@ -31,6 +31,7 @@ module Projects ...@@ -31,6 +31,7 @@ module Projects
{ {
selected_merge_access_levels: @protected_branch.merge_access_levels.map { |access_level| access_level.user_id || access_level.access_level }, selected_merge_access_levels: @protected_branch.merge_access_levels.map { |access_level| access_level.user_id || access_level.access_level },
selected_push_access_levels: @protected_branch.push_access_levels.map { |access_level| access_level.user_id || access_level.access_level }, selected_push_access_levels: @protected_branch.push_access_levels.map { |access_level| access_level.user_id || access_level.access_level },
selected_create_access_levels: @protected_tag.create_access_levels.map { |access_level| access_level.user_id || access_level.access_level },
create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel), create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel),
push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel), push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel),
merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel) merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel)
......
...@@ -24,6 +24,15 @@ module BranchesHelper ...@@ -24,6 +24,15 @@ module BranchesHelper
ProtectedBranch.protected?(project, branch.name) ProtectedBranch.protected?(project, branch.name)
end end
# Returns a hash were keys are types of access levels (user, role), and
# values are the number of access levels of the particular type.
def access_level_frequencies(access_levels)
access_levels.reduce(Hash.new(0)) do |frequencies, access_level|
frequencies[access_level.type] += 1
frequencies
end
end
def access_levels_data(access_levels) def access_levels_data(access_levels)
access_levels.map do |level| access_levels.map do |level|
if level.type == :user if level.type == :user
......
...@@ -3,7 +3,7 @@ module ProtectedBranchAccess ...@@ -3,7 +3,7 @@ module ProtectedBranchAccess
included do included do
include ProtectedRefAccess include ProtectedRefAccess
include EE::ProtectedBranchAccess include EE::ProtectedRefAccess
belongs_to :protected_branch belongs_to :protected_branch
......
...@@ -8,32 +8,50 @@ module ProtectedRef ...@@ -8,32 +8,50 @@ module ProtectedRef
validates :project, presence: true validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher delegate :matching, :matches?, :wildcard?, to: :ref_matcher
end
def commit
project.commit(self.name)
end
class_methods do
def protected_ref_access_levels(*types)
types.each do |type|
has_many :"#{type}_access_levels", dependent: :destroy
validates :"#{type}_access_levels", length: { minimum: 0 }
accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
def self.protected_ref_accessible_to?(ref, user, action:) # Returns access levels that grant the specified access type to the given user / group.
access_level_class = const_get("#{type}_access_level".classify)
protected_type = self.model_name.singular
scope :"#{type}_access_by_user", -> (user) { access_level_class.joins(protected_type.to_sym).where("#{protected_type}_id" => self.ids).merge(access_level_class.by_user(user)) }
scope :"#{type}_access_by_group", -> (group) { access_level_class.joins(protected_type.to_sym).where("#{protected_type}_id" => self.ids).merge(access_level_class.by_group(group)) }
end
end
def protected_ref_accessible_to?(ref, user, action:)
access_levels_for_ref(ref, action: action).any? do |access_level| access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.check_access(user) access_level.check_access(user)
end end
end end
def self.developers_can?(action, ref) def developers_can?(action, ref)
access_levels_for_ref(ref, action: action).any? do |access_level| access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.access_level == Gitlab::Access::DEVELOPER access_level.access_level == Gitlab::Access::DEVELOPER
end end
end end
def self.access_levels_for_ref(ref, action:) def access_levels_for_ref(ref, action:)
self.matching(ref).map(&:"#{action}_access_levels").flatten self.matching(ref).map(&:"#{action}_access_levels").flatten
end end
def self.matching(ref_name, protected_refs: nil) def matching(ref_name, protected_refs: nil)
ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs) ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
end end
end end
def commit
project.commit(self.name)
end
private private
def ref_matcher def ref_matcher
......
...@@ -3,6 +3,7 @@ module ProtectedTagAccess ...@@ -3,6 +3,7 @@ module ProtectedTagAccess
included do included do
include ProtectedRefAccess include ProtectedRefAccess
include EE::ProtectedRefAccess
belongs_to :protected_tag belongs_to :protected_tag
......
# EE-specific code related to protected branch access levels. # EE-specific code related to protected branch/tag access levels.
# #
# Note: Don't directly include this concern into a model class. # Note: Don't directly include this concern into a model class.
# Instead, include `ProtectedBranchAccess`, which in turn includes # Instead, include `ProtectedBranchAccess` or `ProtectedTagAccess`, which in
# this concern. A number of methods here depend on `ProtectedBranchAccess` # turn include this concern. A number of methods here depend on
# being next up in the ancestor chain. # `ProtectedRefAccess` being next up in the ancestor chain.
module EE module EE
module ProtectedBranchAccess module ProtectedRefAccess
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
belongs_to :user belongs_to :user
belongs_to :group belongs_to :group
validates :group_id, uniqueness: { scope: :protected_branch, allow_nil: true } protected_type = self.parent.model_name.singular
validates :user_id, uniqueness: { scope: :protected_branch, allow_nil: true } validates :group_id, uniqueness: { scope: protected_type, allow_nil: true }
validates :access_level, uniqueness: { scope: :protected_branch, if: :role?, validates :user_id, uniqueness: { scope: protected_type, allow_nil: true }
validates :access_level, uniqueness: { scope: protected_type, if: :role?,
conditions: -> { where(user_id: nil, group_id: nil) } } conditions: -> { where(user_id: nil, group_id: nil) } }
scope :by_user, -> (user) { where(user: user ) } scope :by_user, -> (user) { where(user: user ) }
......
...@@ -2,48 +2,7 @@ class ProtectedBranch < ActiveRecord::Base ...@@ -2,48 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
include ProtectedRef include ProtectedRef
has_many :merge_access_levels, dependent: :destroy protected_ref_access_levels :merge, :push
has_many :push_access_levels, dependent: :destroy
validates :merge_access_levels, length: { minimum: 0 }
validates :push_access_levels, length: { minimum: 0 }
accepts_nested_attributes_for :push_access_levels, allow_destroy: true
accepts_nested_attributes_for :merge_access_levels, allow_destroy: true
# Returns all merge access levels (for protected branches in scope) that grant merge
# access to the given user.
scope :merge_access_by_user, -> (user) { MergeAccessLevel.joins(:protected_branch).where(protected_branch_id: self.ids).merge(MergeAccessLevel.by_user(user)) }
# Returns all push access levels (for protected branches in scope) that grant push
# access to the given user.
scope :push_access_by_user, -> (user) { PushAccessLevel.joins(:protected_branch).where(protected_branch_id: self.ids).merge(PushAccessLevel.by_user(user)) }
# Returns all merge access levels (for protected branches in scope) that grant merge
# access to the given group.
scope :merge_access_by_group, -> (group) { MergeAccessLevel.joins(:protected_branch).where(protected_branch_id: self.ids).merge(MergeAccessLevel.by_group(group)) }
# Returns all push access levels (for protected branches in scope) that grant push
# access to the given group.
scope :push_access_by_group, -> (group) { PushAccessLevel.joins(:protected_branch).where(protected_branch_id: self.ids).merge(PushAccessLevel.by_group(group)) }
# Returns a hash were keys are types of push access levels (user, role), and
# values are the number of access levels of the particular type.
def push_access_level_frequencies
push_access_levels.reduce(Hash.new(0)) do |frequencies, access_level|
frequencies[access_level.type] = frequencies[access_level.type] + 1
frequencies
end
end
# Returns a hash were keys are types of merge access levels (user, role), and
# values are the number of access levels of the particular type.
def merge_access_level_frequencies
merge_access_levels.reduce(Hash.new(0)) do |frequencies, access_level|
frequencies[access_level.type] = frequencies[access_level.type] + 1
frequencies
end
end
# Check if branch name is marked as protected in the system # Check if branch name is marked as protected in the system
def self.protected?(project, ref_name) def self.protected?(project, ref_name)
......
...@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base ...@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
include ProtectedRef include ProtectedRef
has_many :create_access_levels, dependent: :destroy protected_ref_access_levels :create
validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
accepts_nested_attributes_for :create_access_levels
def self.protected?(project, ref_name) def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present? self.matching(ref_name, protected_refs: project.protected_tags).present?
......
= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
%h3.panel-title %h3.panel-title
......
%td %td
= render partial: 'projects/protected_branches/access_level_dropdown', locals: { protected_branch: protected_branch, access_levels: protected_branch.merge_access_levels, level_frequencies: protected_branch.merge_access_level_frequencies, input_basic_name: 'merge_access_levels', toggle_class: 'js-allowed-to-merge' } = render partial: 'projects/protected_branches/access_level_dropdown', locals: { protected_branch: protected_branch, access_levels: protected_branch.merge_access_levels, level_frequencies: access_level_frequencies(protected_branch.merge_access_levels), input_basic_name: 'merge_access_levels', toggle_class: 'js-allowed-to-merge' }
%td %td
= render partial: 'projects/protected_branches/access_level_dropdown', locals: { protected_branch: protected_branch, access_levels: protected_branch.push_access_levels, level_frequencies: protected_branch.push_access_level_frequencies, input_basic_name: 'push_access_levels', toggle_class: 'js-allowed-to-push' } = render partial: 'projects/protected_branches/access_level_dropdown', locals: { protected_branch: protected_branch, access_levels: protected_branch.push_access_levels, level_frequencies: access_level_frequencies(protected_branch.push_access_levels), input_basic_name: 'push_access_levels', toggle_class: 'js-allowed-to-push' }
- default_label = 'Select'
- dropdown_label = default_label
%div{ class: "#{input_basic_name}-container" }
- if access_levels.present?
- dropdown_label = [pluralize(level_frequencies[:role], 'role'), pluralize(level_frequencies[:user], 'user'), pluralize(level_frequencies[:group], 'group')].to_sentence
= dropdown_tag(dropdown_label, options: { toggle_class: "#{toggle_class} js-multiselect", dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
data: { default_label: default_label, preselected_items: access_levels_data(access_levels) } })
= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f| = form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
%h3.panel-title %h3.panel-title
...@@ -24,9 +24,13 @@ ...@@ -24,9 +24,13 @@
.col-md-10 .col-md-10
.create_access_levels-container .create_access_levels-container
= dropdown_tag('Select', = dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide', options: { toggle_class: 'js-allowed-to-create js-multiselect wide',
dropdown_class: 'dropdown-menu-selectable', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) data: { input_id: 'create_access_levels_attributes', default_label: 'Select' } })
.help-block
Only groups that
= link_to 'have this project shared', help_page_path('workflow/share_projects_with_other_groups')
can be added here
.panel-footer .panel-footer
= f.submit 'Protect', class: 'btn-create btn', disabled: true = f.submit 'Protect', class: 'btn-create btn', disabled: true
...@@ -10,6 +10,6 @@ ...@@ -10,6 +10,6 @@
%ul.dropdown-footer-list %ul.dropdown-footer-list
%li %li
= link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
Create wildcard Create wildcard
%code %code
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_tags') = page_specific_javascript_bundle_tag('protected_tags')
.row.prepend-top-default.append-bottom-default .row.prepend-top-default.append-bottom-default.js-protected-tags-container{ data: { "groups-autocomplete" => "#{autocomplete_project_groups_path(format: :json)}", "users-autocomplete" => "#{autocomplete_users_path(format: :json)}" } }
.col-lg-3 .col-lg-3
%h4.prepend-top-0 %h4.prepend-top-0
Protected tags Protected Tags
%p.prepend-top-20 %p.prepend-top-20
By default, Protected tags are designed to: By default, protected tags are designed to:
%ul %ul
%li Prevent tag creation by everybody except Masters %li Prevent tag creation by everybody except Masters
%li Prevent <strong>anyone</strong> from updating the tag %li Prevent <strong>anyone</strong> from updating the tag
%li Prevent <strong>anyone</strong> from deleting the tag %li Prevent <strong>anyone</strong> from deleting the tag
%p.append-bottom-0 Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}.
.col-lg-9 .col-lg-9
- if can? current_user, :admin_project, @project - if can? current_user, :admin_project, @project
= render 'projects/protected_tags/create_protected_tag' = render 'projects/protected_tags/create_protected_tag'
......
...@@ -15,8 +15,8 @@ ...@@ -15,8 +15,8 @@
- else - else
(tag was removed from repository) (tag was removed from repository)
= render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag } = render partial: 'projects/protected_tags/protected_tag_access_summary', locals: { protected_tag: protected_tag }
- if can_admin_project - if can_admin_project
%td %td
= link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
%td
= render partial: 'projects/protected_tags/access_level_dropdown', locals: { protected_tag: protected_tag, access_levels: protected_tag.create_access_levels, level_frequencies: access_level_frequencies(protected_tag.create_access_levels), input_basic_name: 'create_access_levels', toggle_class: 'js-allowed-to-create' }
.panel.panel-default.protected-tags-list.js-protected-tags-list .panel.panel-default.protected-tags-list
- if @protected_tags.empty? - if @protected_tags.empty?
.panel-heading .panel-heading
%h3.panel-title %h3.panel-title
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
%col{ width: "25%" } %col{ width: "25%" }
%col{ width: "25%" } %col{ width: "25%" }
%col{ width: "50%" } %col{ width: "50%" }
- if can_admin_project
%col
%thead %thead
%tr %tr
%th Protected tag (#{@protected_tags.size}) %th Protected tag (#{@protected_tags.size})
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
%h4.prepend-top-0.ref-name %h4.prepend-top-0.ref-name
= @protected_ref.name = @protected_ref.name
.col-lg-9 .col-lg-9.edit_protected_tag
%h5 Matching Tags %h5 Matching Tags
- if @matching_refs.present? - if @matching_refs.present?
.table-responsive .table-responsive
......
---
title: Per user/group access levels for Protected Tags
merge_request: 1629
author:
...@@ -7,16 +7,38 @@ FactoryGirl.define do ...@@ -7,16 +7,38 @@ FactoryGirl.define do
protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER) protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
end end
transient do
authorize_user_to_create nil
authorize_group_to_create nil
end
trait :remove_default_access_levels do
after(:build) do |protected_tag|
protected_tag.create_access_levels = []
end
end
trait :developers_can_create do trait :developers_can_create do
after(:create) do |protected_tag| after(:create) do |protected_tag|
protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) protected_tag.create_access_levels.create!(access_level: Gitlab::Access::DEVELOPER)
end end
end end
trait :no_one_can_create do trait :no_one_can_create do
after(:create) do |protected_tag| after(:create) do |protected_tag|
protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) protected_tag.create_access_levels.create!(access_level: Gitlab::Access::NO_ACCESS)
end end
end end
trait :masters_can_create do
after(:create) do |protected_tag|
protected_tag.create_access_levels.create!(access_level: Gitlab::Access::MASTER)
end
end
after(:create) do |protected_tag, evaluator|
protected_tag.create_access_levels.create!(user: evaluator.authorize_user_to_create) if evaluator.authorize_user_to_create
protected_tag.create_access_levels.create!(group: evaluator.authorize_group_to_create) if evaluator.authorize_group_to_create
end
end end
end end
FactoryGirl.define do
factory :protected_tag_create_access_level, class: ProtectedTag::CreateAccessLevel do
user nil
group nil
protected_tag
access_level { Gitlab::Access::DEVELOPER }
end
end
...@@ -6,6 +6,17 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -6,6 +6,17 @@ feature 'Projected Tags', feature: true, js: true do
before { login_as(user) } before { login_as(user) }
def set_allowed_to(operation, option = 'Masters', form: '.new-protected-tag')
within form do
find(".js-allowed-to-#{operation}").click
wait_for_ajax
Array(option).each { |opt| click_on(opt) }
find(".js-allowed-to-#{operation}").click # needed to submit form in some cases
end
end
def set_protected_tag_name(tag_name) def set_protected_tag_name(tag_name)
find(".js-protected-tag-select").click find(".js-protected-tag-select").click
find(".dropdown-input-field").set(tag_name) find(".dropdown-input-field").set(tag_name)
...@@ -17,6 +28,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -17,6 +28,7 @@ feature 'Projected Tags', feature: true, js: true do
it "allows creating explicit protected tags" do it "allows creating explicit protected tags" do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag') set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('some-tag') } within(".protected-tags-list") { expect(page).to have_content('some-tag') }
...@@ -30,6 +42,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -30,6 +42,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag') set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content(commit.id[0..7]) } within(".protected-tags-list") { expect(page).to have_content(commit.id[0..7]) }
...@@ -38,6 +51,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -38,6 +51,7 @@ feature 'Projected Tags', feature: true, js: true do
it "displays an error message if the named tag does not exist" do it "displays an error message if the named tag does not exist" do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag') set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('tag was removed') } within(".protected-tags-list") { expect(page).to have_content('tag was removed') }
...@@ -48,6 +62,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -48,6 +62,7 @@ feature 'Projected Tags', feature: true, js: true do
it "allows creating protected tags with a wildcard" do it "allows creating protected tags with a wildcard" do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable') set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('*-stable') } within(".protected-tags-list") { expect(page).to have_content('*-stable') }
...@@ -61,6 +76,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -61,6 +76,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable') set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content("2 matching tags") } within(".protected-tags-list") { expect(page).to have_content("2 matching tags") }
...@@ -73,6 +89,7 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -73,6 +89,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable') set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect" click_on "Protect"
visit namespace_project_protected_tags_path(project.namespace, project) visit namespace_project_protected_tags_path(project.namespace, project)
...@@ -87,6 +104,6 @@ feature 'Projected Tags', feature: true, js: true do ...@@ -87,6 +104,6 @@ feature 'Projected Tags', feature: true, js: true do
end end
describe "access control" do describe "access control" do
include_examples "protected tags > access control > CE" include_examples "protected tags > access control > EE"
end end
end end
...@@ -152,7 +152,9 @@ push_access_levels: ...@@ -152,7 +152,9 @@ push_access_levels:
- protected_branch - protected_branch
- group - group
create_access_levels: create_access_levels:
- user
- protected_tag - protected_tag
- group
container_repositories: container_repositories:
- project - project
- name - name
......
RSpec.shared_examples "protected tags > access control > EE" do
let(:users) { create_list(:user, 5) }
let(:groups) { create_list(:group, 5) }
let(:roles) { ProtectedTag::CreateAccessLevel.human_access_levels.except(0) }
before do
users.each { |user| project.team << [user, :developer] }
groups.each { |group| project.project_group_links.create(group: group, group_access: Gitlab::Access::DEVELOPER) }
end
def access_type_ids
ProtectedTag.last.create_access_levels
end
def access_levels
access_type_ids.map(&:access_level)
end
def user_ids
access_type_ids.map(&:user_id)
end
def group_ids
access_type_ids.map(&:group_id)
end
it "allows creating protected tags that roles, users, and groups can create" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('v1.0')
set_allowed_to('create', users.map(&:name))
set_allowed_to('create', groups.map(&:name))
set_allowed_to('create', roles.values)
click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('v1.0') }
expect(ProtectedTag.count).to eq(1)
roles.each { |(access_type_id, _)| expect(access_levels).to include(access_type_id) }
users.each { |user| expect(user_ids).to include(user.id) }
groups.each { |group| expect(group_ids).to include(group.id) }
end
it "allows updating protected tags so that roles and users can create it" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('v1.0')
set_allowed_to('create')
click_on "Protect"
set_allowed_to('create', users.map(&:name), form: ".js-protected-tag-edit-form")
set_allowed_to('create', groups.map(&:name), form: ".js-protected-tag-edit-form")
set_allowed_to('create', roles.values, form: ".js-protected-tag-edit-form")
wait_for_ajax
expect(ProtectedTag.count).to eq(1)
roles.each { |(access_type_id, _)| expect(access_levels).to include(access_type_id) }
users.each { |user| expect(user_ids).to include(user.id) }
groups.each { |group| expect(group_ids).to include(group.id) }
end
it "allows updating protected tags so that roles and users cannot create it" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('v1.0')
users.each { |user| set_allowed_to('create', user.name) }
roles.each { |(_, access_type_name)| set_allowed_to('create', access_type_name) }
groups.each { |group| set_allowed_to('create', group.name) }
click_on "Protect"
users.each { |user| set_allowed_to('create', user.name, form: ".js-protected-tag-edit-form") }
groups.each { |group| set_allowed_to('create', group.name, form: ".js-protected-tag-edit-form") }
roles.each { |(_, access_type_name)| set_allowed_to('create', access_type_name, form: ".js-protected-tag-edit-form") }
wait_for_ajax
expect(ProtectedTag.count).to eq(1)
expect(access_type_ids).to be_empty
end
it "prepends selected users that can create" do
users = create_list(:user, 21)
users.each { |user| project.team << [user, :developer] }
visit namespace_project_protected_tags_path(project.namespace, project)
# Create Protected Tag
set_protected_tag_name('v1.0')
set_allowed_to('create', roles.values)
click_on 'Protect'
# Update Protected Tag
within(".protected-tags-list") do
find(".js-allowed-to-create").click
find(".dropdown-input-field").set(users.last.name) # Find a user that is not loaded
expect(page).to have_selector('.dropdown-header', count: 3)
%w{Roles Groups Users}.each_with_index do |header, index|
expect(all('.dropdown-header')[index]).to have_content(header)
end
wait_for_ajax
click_on users.last.name
find(".js-allowed-to-create").click # close
end
wait_for_ajax
# Verify the user is appended in the dropdown
find(".protected-tags-list .js-allowed-to-create").click
expect(page).to have_selector '.dropdown-content .is-active', text: users.last.name
expect(ProtectedTag.count).to eq(1)
roles.each { |(access_type_id, _)| expect(access_levels).to include(access_type_id) }
expect(user_ids).to include(users.last.id)
end
context 'When updating a protected tag' do
it 'discards other roles when choosing "No one"' do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('fix')
set_allowed_to('create', roles.values)
click_on "Protect"
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(access_levels).to include(access_type_id)
end
expect(access_levels).not_to include(0)
set_allowed_to('create', 'No one', form: '.js-protected-tag-edit-form')
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(access_levels).not_to include(access_type_id)
end
expect(access_levels).to include(0)
end
end
context 'When creating a protected tag' do
it 'discards other roles when choosing "No one"' do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('v1.0')
set_allowed_to('create', ProtectedTag::CreateAccessLevel.human_access_levels.values) # Last item (No one) should deselect the other ones
click_on "Protect"
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(access_levels).not_to include(access_type_id)
end
expect(access_levels).to include(0)
end
end
end
...@@ -5,7 +5,7 @@ shared_examples "protected branches > access control > CE" do ...@@ -5,7 +5,7 @@ shared_examples "protected branches > access control > CE" do
set_protected_branch_name('master') set_protected_branch_name('master')
within('.new_protected_branch') do within('.js-new-protected-branch') do
allowed_to_push_button = find(".js-allowed-to-push") allowed_to_push_button = find(".js-allowed-to-push")
unless allowed_to_push_button.text == access_type_name unless allowed_to_push_button.text == access_type_name
...@@ -50,7 +50,7 @@ shared_examples "protected branches > access control > CE" do ...@@ -50,7 +50,7 @@ shared_examples "protected branches > access control > CE" do
set_protected_branch_name('master') set_protected_branch_name('master')
within('.new_protected_branch') do within('.js-new-protected-branch') do
allowed_to_merge_button = find(".js-allowed-to-merge") allowed_to_merge_button = find(".js-allowed-to-merge")
unless allowed_to_merge_button.text == access_type_name unless allowed_to_merge_button.text == access_type_name
......
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