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;
/* eslint-disable no-underscore-dangle, class-methods-use-this */
/* global Flash */
import { ACCESS_LEVELS, LEVEL_TYPES, ACCESS_LEVEL_NONE } from './constants';
export default class ProtectedTagAccessDropdown {
constructor(options) {
const {
$dropdown,
accessLevel,
accessLevelsData,
} = options;
this.options = options;
this.isAllowedToCreateDropdown = false;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData.roles;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.$protectedTagsContainer = $('.js-protected-tags-container');
this.usersPath = this.$protectedTagsContainer.data('users-autocomplete');
this.groupsPath = this.$protectedTagsContainer.data('groups-autocomplete');
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
this.persistPreselectedItems();
if (ACCESS_LEVELS.CREATE === this.accessLevel) {
this.isAllowedToCreateDropdown = true;
this.noOneObj = this.accessLevelsData.find(level => level.id === ACCESS_LEVEL_NONE);
}
this.initDropdown();
}
initDropdown() {
const { onSelect } = this.options;
this.options.$dropdown.glDropdown({
data: this.options.data,
const self = this;
const { onSelect, onHide } = this.options;
this.$dropdown.glDropdown({
data: this.getData.bind(this),
selectable: true,
inputId: this.options.$dropdown.data('input-id'),
fieldName: this.options.$dropdown.data('field-name'),
toggleLabel(item, $el) {
filterable: true,
filterRemote: true,
multiSelect: this.$dropdown.hasClass('js-multiselect'),
renderRow: this.renderRow.bind(this),
toggleLabel: this.toggleLabel.bind(this),
hidden() {
if (onHide) {
onHide();
}
},
clicked: (options) => {
const { $el, e } = options;
const item = options.selectedObj;
e.preventDefault();
if ($el.is('.is-active')) {
return item.text;
if (self.isAllowedToCreateDropdown) {
if (item.id === self.noOneObj.id) {
self.accessLevelsData.forEach((level) => {
if (level.id !== item.id) {
self.removeSelectedItem(level);
}
});
self.$wrap.find(`.item-${item.type}`).removeClass('is-active');
} else {
const $noOne = self.$wrap.find(`.is-active.item-${item.type}[data-role-id="${self.noOneObj.id}"]`);
if ($noOne.length) {
$noOne.removeClass('is-active');
self.removeSelectedItem(self.noOneObj);
}
}
$el.addClass(`is-active item-${item.type}`);
}
self.addSelectedItem(item);
} else {
self.removeSelectedItem(item);
}
if (onSelect) {
onSelect(item, $el, this);
}
return 'Select';
},
clicked(options) {
options.e.preventDefault();
onSelect();
});
}
persistPreselectedItems() {
const itemsToPreselect = this.$dropdown.data('preselectedItems');
if (!itemsToPreselect || !itemsToPreselect.length) {
return;
}
const persistedItems = itemsToPreselect.map((item) => {
const persistedItem = Object.assign({}, item);
persistedItem.persisted = true;
return persistedItem;
});
this.setSelectedItems(persistedItems);
}
setSelectedItems(items = []) {
this.items = items;
}
getSelectedItems() {
return this.items.filter(item => !item._destroy);
}
getAllSelectedItems() {
return this.items;
}
getInputData() {
const selectedItems = this.getAllSelectedItems();
const accessLevels = selectedItems.map((item) => {
const obj = {};
if (typeof item.id !== 'undefined') {
obj.id = item.id;
}
if (typeof item._destroy !== 'undefined') {
obj._destroy = item._destroy;
}
if (item.type === LEVEL_TYPES.ROLE) {
obj.access_level = item.access_level;
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
return obj;
});
return accessLevels;
}
addSelectedItem(selectedItem) {
let itemToAdd = {};
// If the item already exists, just use it
let index = -1;
const selectedItems = this.getAllSelectedItems();
selectedItems.forEach((item, i) => {
if (selectedItem.id === item.access_level) {
index = i;
}
});
if (index !== -1 && selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
return;
}
itemToAdd.type = selectedItem.type;
if (selectedItem.type === LEVEL_TYPES.USER) {
itemToAdd = {
user_id: selectedItem.id,
name: selectedItem.name || '-name1',
username: selectedItem.username || '-username1',
avatar_url: selectedItem.avatar_url || '-avatar_url1',
type: LEVEL_TYPES.USER,
};
} else if (selectedItem.type === LEVEL_TYPES.ROLE) {
itemToAdd = {
access_level: selectedItem.id,
type: LEVEL_TYPES.ROLE,
};
} else if (selectedItem.type === LEVEL_TYPES.GROUP) {
itemToAdd = {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP,
};
}
this.items.push(itemToAdd);
}
removeSelectedItem(itemToDelete) {
let index = -1;
const selectedItems = this.getAllSelectedItems();
// To find itemToDelete on selectedItems, first we need the index
selectedItems.every((item, i) => {
if (item.type !== itemToDelete.type) {
return true;
}
if (item.type === LEVEL_TYPES.USER &&
item.user_id === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.ROLE &&
item.access_level === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.GROUP &&
item.group_id === itemToDelete.id) {
index = i;
}
// Break once we have index set
return !(index > -1);
});
// if ItemToDelete is not really selected do nothing
if (index === -1) {
return;
}
if (selectedItems[index].persisted) {
// If we toggle an item that has been already marked with _destroy
if (selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
} else {
selectedItems[index]._destroy = '1';
}
} else {
selectedItems.splice(index, 1);
}
}
toggleLabel() {
const currentItems = this.getSelectedItems();
const types = _.groupBy(currentItems, item => item.type);
let label = [];
if (currentItems.length) {
label = Object.keys(LEVEL_TYPES).map((levelType) => {
const typeName = LEVEL_TYPES[levelType];
const numberOfTypes = types[typeName] ? types[typeName].length : 0;
const text = numberOfTypes === 1 ? typeName : `${typeName}s`;
return `${numberOfTypes} ${text}`;
});
} else {
label.push(this.defaultLabel);
}
this.$dropdown.find('.dropdown-toggle-text').toggleClass('is-default', !currentItems.length);
return label.join(', ');
}
getData(query, callback) {
this.getUsers(query)
.done((usersResponse) => {
if (this.groups.length) {
callback(this.consolidateData(usersResponse, this.groups));
} else {
this.getGroups(query)
.done((groupsResponse) => {
// Cache groups to avoid multiple requests
this.groups = groupsResponse;
callback(this.consolidateData(usersResponse, groupsResponse));
})
.error(() => new Flash('Failed to load groups.'));
}
})
.error(() => new Flash('Failed to load users.'));
}
consolidateData(usersResponse, groupsResponse) {
let consolidatedData = [];
const map = [];
const selectedItems = this.getSelectedItems();
// ID property is handled differently locally from the server
//
// For Groups
// In dropdown: `id`
// For submit: `group_id`
//
// For Roles
// In dropdown: `id`
// For submit: `access_level`
//
// For Users
// In dropdown: `id`
// For submit: `user_id`
/*
* Build groups
*/
const groups = groupsResponse.map(group => ({ ...group, type: LEVEL_TYPES.GROUP }));
/*
* Build roles
*/
const roles = this.accessLevelsData.map((level) => {
/* eslint-disable no-param-reassign */
// This re-assignment is intentional as
// level.type property is being used in removeSelectedItem()
// for comparision, and accessLevelsData is provided by
// gon.create_access_levels which doesn't have `type` included.
// See this discussion https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1629#note_31285823
level.type = LEVEL_TYPES.ROLE;
return level;
});
/*
* Build users
*/
const users = selectedItems.filter(item => item.type === LEVEL_TYPES.USER).map((item) => {
// Save identifiers for easy-checking more later
map.push(LEVEL_TYPES.USER + item.user_id);
return {
id: item.user_id,
name: item.name,
username: item.username,
avatar_url: item.avatar_url,
type: LEVEL_TYPES.USER,
};
});
// Has to be checked against server response
// because the selected item can be in filter results
usersResponse.forEach((response) => {
// Add is it has not been added
if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
const user = Object.assign({}, response);
user.type = LEVEL_TYPES.USER;
users.push(user);
}
});
if (roles.length) {
consolidatedData = consolidatedData.concat([{ header: 'Roles' }], roles);
}
if (groups.length) {
if (roles.length) {
consolidatedData = consolidatedData.concat(['divider']);
}
consolidatedData = consolidatedData.concat([{ header: 'Groups' }], groups);
}
if (users.length) {
consolidatedData = consolidatedData.concat(['divider'], [{ header: 'Users' }], users);
}
return consolidatedData;
}
getUsers(query) {
return $.ajax({
dataType: 'json',
url: this.buildUrl(gon.relative_url_root, this.usersPath),
data: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
}
getGroups() {
return $.ajax({
dataType: 'json',
url: this.buildUrl(gon.relative_url_root, this.groupsPath),
data: {
project_id: gon.current_project_id,
},
});
}
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot !== null) {
newUrl = urlRoot.replace(/\/$/, '') + url;
}
return newUrl;
}
renderRow(item) {
let criteria = {};
let groupRowEl;
// Detect if the current item is already saved so we can add
// the `is-active` class so the item looks as marked
switch (item.type) {
case LEVEL_TYPES.USER:
criteria = { user_id: item.id };
break;
case LEVEL_TYPES.ROLE:
criteria = { access_level: item.id };
break;
case LEVEL_TYPES.GROUP:
criteria = { group_id: item.id };
break;
default:
break;
}
const isActive = _.findWhere(this.getSelectedItems(), criteria) ? 'is-active' : '';
switch (item.type) {
case LEVEL_TYPES.USER:
groupRowEl = this.userRowHtml(item, isActive);
break;
case LEVEL_TYPES.ROLE:
groupRowEl = this.roleRowHtml(item, isActive);
break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
break;
default:
groupRowEl = '';
break;
}
return groupRowEl;
}
userRowHtml(user, isActive) {
const isActiveClass = isActive || '';
return `
<li>
<a href="#" class="${isActiveClass}">
<img src="${user.avatar_url}" class="avatar avatar-inline" width="30">
<strong class="dropdown-menu-user-full-name">${user.name}</strong>
<span class="dropdown-menu-user-username">${user.username}</span>
</a>
</li>
`;
}
groupRowHtml(group, isActive) {
const isActiveClass = isActive || '';
const avatarEl = group.avatar_url ? `<img src="${group.avatar_url}" class="avatar avatar-inline" width="30">` : '';
return `
<li>
<a href="#" class="${isActiveClass}">
${avatarEl}
<span class="dropdown-menu-group-groupname">${group.name}</span>
</a>
</li>
`;
}
roleRowHtml(role, isActive) {
const isActiveClass = isActive || '';
return `
<li>
<a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}">
${role.text}
</a>
</li>
`;
}
}
/* global Flash */
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
import ProtectedTagDropdown from './protected_tag_dropdown';
......@@ -5,6 +8,12 @@ export default class ProtectedTagCreate {
constructor() {
this.$form = $('.js-new-protected-tag');
this.buildDropdowns();
this.$branchTag = this.$form.find('input[name="protected_tag[name]"]');
this.bindEvents();
}
bindEvents() {
this.$form.on('submit', this.onFormSubmit.bind(this));
}
buildDropdowns() {
......@@ -14,15 +23,13 @@ export default class ProtectedTagCreate {
this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Create dropdown
this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
this[`${ACCESS_LEVELS.CREATE}_dropdown`] = new ProtectedTagAccessDropdown({
$dropdown: $allowedToCreateDropdown,
data: gon.create_access_levels,
accessLevelsData: gon.create_access_levels,
onSelect: this.onSelectCallback,
accessLevel: ACCESS_LEVELS.CREATE,
});
// Select default
$allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
// Protected tag dropdown
this.protectedTagDropdown = new ProtectedTagDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
......@@ -30,12 +37,60 @@ export default class ProtectedTagCreate {
});
}
// This will run after clicked callback
// Enable submit button after selecting an option
onSelect() {
// Enable submit button
const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
const $allowedToCreate = this[`${ACCESS_LEVELS.CREATE}_dropdown`].getSelectedItems();
const toggle = !(this.$form.find('input[name="protected_tag[name]"]').val() && $allowedToCreate.length);
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 {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
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.bindEvents();
......@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
.find('.create-new-protected-tag code')
.find('.js-create-new-protected-tag code')
.text(tagName);
}
......
/* eslint-disable no-new */
/* global Flash */
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
export default class ProtectedTagEdit {
constructor(options) {
this.hasChanges = false;
this.$wrap = options.$wrap;
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();
}
buildDropdowns() {
// 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,
data: gon.create_access_levels,
onSelect: this.onSelectCallback,
onSelect: this.onSelectOption.bind(this),
onHide: this.onDropdownHide.bind(this),
});
}
onSelect() {
const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`);
onSelectOption() {
this.hasChanges = true;
}
onDropdownHide() {
if (!this.hasChanges) {
return;
}
this.hasChanges = true;
this.updatePermissions();
}
// Do not update if one dropdown has not selected any option
if (!$allowedToCreateInput.length) return;
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;
this.$allowedToCreateDropdownButton.disable();
return acc;
}, {});
$.ajax({
return $.ajax({
type: 'POST',
url: this.$wrap.data('url'),
dataType: 'json',
data: {
_method: 'PATCH',
protected_tag: {
create_access_levels_attributes: [{
id: this.$allowedToCreateDropdownButton.data('access-level-id'),
access_level: $allowedToCreateInput.val(),
}],
protected_tag: formData,
},
success: (response) => {
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() {
new Flash('Failed to update tag!', null, $('.js-protected-tags-list'));
$.scrollTo(0);
new Flash('Failed to update tag!');
},
}).always(() => {
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 {
}
}
.new_protected_branch {
a.allowed-to-merge,
a.allowed-to-push {
cursor: pointer;
}
.new-protected-branch,
.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
a.allowed-to-merge,
a.allowed-to-push {
cursor: pointer;
}
.protected-branch-push-access-list,
.protected-branch-merge-access-list {
a {
......@@ -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;
width: 100%;
......
......@@ -23,7 +23,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id],
push_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id])
merge_access_levels_attributes: access_level_attributes,
push_access_levels_attributes: access_level_attributes)
end
end
......@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok }
end
end
protected
def access_level_attributes
%i(access_level id user_id _destroy group_id)
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
end
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
......@@ -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_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),
push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel),
merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel)
......
......@@ -24,6 +24,15 @@ module BranchesHelper
ProtectedBranch.protected?(project, branch.name)
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)
access_levels.map do |level|
if level.type == :user
......
......@@ -3,7 +3,7 @@ module ProtectedBranchAccess
included do
include ProtectedRefAccess
include EE::ProtectedBranchAccess
include EE::ProtectedRefAccess
belongs_to :protected_branch
......
......@@ -8,32 +8,50 @@ module ProtectedRef
validates :project, presence: true
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_level.check_access(user)
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_level.access_level == Gitlab::Access::DEVELOPER
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
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)
end
end
def commit
project.commit(self.name)
end
private
def ref_matcher
......
......@@ -3,6 +3,7 @@ module ProtectedTagAccess
included do
include ProtectedRefAccess
include EE::ProtectedRefAccess
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.
# Instead, include `ProtectedBranchAccess`, which in turn includes
# this concern. A number of methods here depend on `ProtectedBranchAccess`
# being next up in the ancestor chain.
# Instead, include `ProtectedBranchAccess` or `ProtectedTagAccess`, which in
# turn include this concern. A number of methods here depend on
# `ProtectedRefAccess` being next up in the ancestor chain.
module EE
module ProtectedBranchAccess
module ProtectedRefAccess
extend ActiveSupport::Concern
included do
belongs_to :user
belongs_to :group
validates :group_id, uniqueness: { scope: :protected_branch, allow_nil: true }
validates :user_id, uniqueness: { scope: :protected_branch, allow_nil: true }
validates :access_level, uniqueness: { scope: :protected_branch, if: :role?,
protected_type = self.parent.model_name.singular
validates :group_id, uniqueness: { scope: protected_type, allow_nil: true }
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) } }
scope :by_user, -> (user) { where(user: user ) }
......
......@@ -2,48 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
has_many :merge_access_levels, dependent: :destroy
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
protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
def self.protected?(project, ref_name)
......
......@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
has_many :create_access_levels, dependent: :destroy
validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
accepts_nested_attributes_for :create_access_levels
protected_ref_access_levels :create
def self.protected?(project, ref_name)
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-heading
%h3.panel-title
......
%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
= 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-heading
%h3.panel-title
......@@ -24,9 +24,13 @@
.col-md-10
.create_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide',
dropdown_class: 'dropdown-menu-selectable',
data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
options: { toggle_class: 'js-allowed-to-create js-multiselect wide',
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
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
= f.submit 'Protect', class: 'btn-create btn', disabled: true
......@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%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
%code
- content_for :page_specific_javascripts do
= 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
%h4.prepend-top-0
Protected tags
Protected Tags
%p.prepend-top-20
By default, Protected tags are designed to:
By default, protected tags are designed to:
%ul
%li Prevent tag creation by everybody except Masters
%li Prevent <strong>anyone</strong> from updating 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
- if can? current_user, :admin_project, @project
= render 'projects/protected_tags/create_protected_tag'
......
......@@ -15,8 +15,8 @@
- else
(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
%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?
.panel-heading
%h3.panel-title
......@@ -13,6 +13,8 @@
%col{ width: "25%" }
%col{ width: "25%" }
%col{ width: "50%" }
- if can_admin_project
%col
%thead
%tr
%th Protected tag (#{@protected_tags.size})
......
......@@ -5,7 +5,7 @@
%h4.prepend-top-0.ref-name
= @protected_ref.name
.col-lg-9
.col-lg-9.edit_protected_tag
%h5 Matching Tags
- if @matching_refs.present?
.table-responsive
......
---
title: Per user/group access levels for Protected Tags
merge_request: 1629
author:
......@@ -7,16 +7,38 @@ FactoryGirl.define do
protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER)
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
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
trait :no_one_can_create do
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
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
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
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)
find(".js-protected-tag-select").click
find(".dropdown-input-field").set(tag_name)
......@@ -17,6 +28,7 @@ feature 'Projected Tags', feature: true, js: true do
it "allows creating explicit protected tags" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('some-tag') }
......@@ -30,6 +42,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect"
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
it "displays an error message if the named tag does not exist" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('some-tag')
set_allowed_to('create')
click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('tag was removed') }
......@@ -48,6 +62,7 @@ feature 'Projected Tags', feature: true, js: true do
it "allows creating protected tags with a wildcard" do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content('*-stable') }
......@@ -61,6 +76,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect"
within(".protected-tags-list") { expect(page).to have_content("2 matching tags") }
......@@ -73,6 +89,7 @@ feature 'Projected Tags', feature: true, js: true do
visit namespace_project_protected_tags_path(project.namespace, project)
set_protected_tag_name('*-stable')
set_allowed_to('create')
click_on "Protect"
visit namespace_project_protected_tags_path(project.namespace, project)
......@@ -87,6 +104,6 @@ feature 'Projected Tags', feature: true, js: true do
end
describe "access control" do
include_examples "protected tags > access control > CE"
include_examples "protected tags > access control > EE"
end
end
......@@ -152,7 +152,9 @@ push_access_levels:
- protected_branch
- group
create_access_levels:
- user
- protected_tag
- group
container_repositories:
- project
- 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
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")
unless allowed_to_push_button.text == access_type_name
......@@ -50,7 +50,7 @@ shared_examples "protected branches > access control > CE" do
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")
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