Commit 2b252c9a authored by Douwe Maan's avatar Douwe Maan

Merge branch '675-protected-branch-specific-groups' into 'master'

Restrict pushes / merges to a protected branch to specific groups

- Closes #675 and https://gitlab.com/gitlab-org/gitlab-ce/issues/21153
- Related to #179 

**Default State**
![Screen_Shot_2016-09-22_at_12.53.27_PM](/uploads/3777bc09fc5b0f9ddeccbf52dbf0277e/Screen_Shot_2016-09-22_at_12.53.27_PM.png)

**Filtering**
![Screen_Shot_2016-09-22_at_12.53.39_PM](/uploads/1e649e2a3720a4b0d7ff3e4fbafe5a72/Screen_Shot_2016-09-22_at_12.53.39_PM.png)

# Tasks

- [ ]  ee#675 !645 Restrict pushes/merges to specific groups
    - [x]  Implementation
        - [x]  Basic model-level implementation
        - [x]  Test / refactor
        - [x]  Rudimentary frontend (controller actions + autocomplete for the dropdown)
            - [x]  Groups that a project hasn't been shared with can't be selected
        - [x]  Look for edge cases
    - [x]  Questions
        - [x]  What are LDAP group links?
            - A GitLab group can be synced with one or more LDAP groups
            - The sync happens in an async task, an the LDAP group users are _imported_ into the GitLab group
            - `group.users` on a GitLab group returns the LDAP group users as well (after the import task has run)
            - We check `group.users` for this feature, so we shouldn't need any additional work to support LDAP groups
    - [x]  CHANGELOG
    - [x]  Removing a group should remove the access
    - [x]  `autocomplete/groups` issue
    - [x]  Fix uniqueness validation
    - [x]  Assign to @alfredo1 for UI work
    - [x]  Wait for !581 to be merged
    - [x]  Rebase against EE master instead of !581
    - [x]  Add feature specs
    - [x]  Implement frontend
    - [ ]  Assign to endboss
    - [ ]  Get backend + frontend reviewed
    - [ ]  Wait for merge


See merge request !645
parents 179cf7c7 6e529de9
......@@ -4,6 +4,7 @@ Please view this file on the master branch, on stable branches it's out of date.
- Add user activity table and service to query for active users
- Fix 500 error updating mirror URLs for projects
- Restrict protected branch access to specific groups !645
- Fix validations related to mirroring settings form. !773
- Add multiple issue boards. !782
- Fix Git access panel for Wikis when Kerberos authentication is enabled (Borja Aparicio)
......
(global => {
global.gl = global.gl || {};
const PUSH_ACCESS_LEVEL = 'push_access_levels';
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user'
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchAccessDropdown = class {
......@@ -17,16 +19,24 @@
accessLevelsData
} = options;
this.isAllowedToPushDropdown = false;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/autocomplete/users.json';
this.groupsPath = '/autocomplete/project_groups.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
this.persistPreselectedItems();
if (PUSH_ACCESS_LEVEL === this.accessLevel) {
this.isAllowedToPushDropdown = true;
this.noOneObj = this.accessLevelsData[2];
}
$dropdown.glDropdown({
selectable: true,
filterable: true,
......@@ -44,6 +54,31 @@
e.preventDefault();
if ($el.is('.is-active')) {
if (self.isAllowedToPushDropdown) {
if (item.id === self.noOneObj.id) {
// remove all others selected items
self.accessLevelsData.forEach((level) => {
if (level.id !== item.id) {
self.removeSelectedItem(level);
}
});
// remove selected item visually
self.$wrap.find(`.item-${item.type}`).removeClass(`is-active`);
} else {
let $noOne = self.$wrap.find(`.is-active.item-${item.type}:contains('No one')`);
if ($noOne.length) {
$noOne.removeClass('is-active');
self.removeSelectedItem(self.noOneObj);
}
}
// make element active right away
$el.addClass(`is-active item-${item.type}`);
}
// Add "No one"
self.addSelectedItem(item);
} else {
self.removeSelectedItem(item);
......@@ -104,6 +139,8 @@
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;
}
accessLevels.push(obj);
......@@ -115,45 +152,77 @@
addSelectedItem(selectedItem) {
var itemToAdd = {};
// If the item already exists, just use it
let index = -1;
let selectedItems = this.getAllSelectedItems();
for (var i = 0; i < selectedItems.length; i++) {
if (selectedItem.id === selectedItems[i].access_level) {
index = i;
continue;
}
}
if (index !== -1 && selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
return;
}
itemToAdd.type = selectedItem.type;
if (selectedItem.type === 'user') {
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: 'user'
type: LEVEL_TYPES.USER
};
} else if (selectedItem.type === 'role') {
} else if (selectedItem.type === LEVEL_TYPES.ROLE) {
itemToAdd = {
access_level: selectedItem.id,
type: 'role'
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;
let index = -1;
let selectedItems = this.getAllSelectedItems();
// To find itemToDelete on selectedItems, first we need the index
for (let i = 0; i < selectedItems.length; i++) {
let currentItem = selectedItems[i];
if (currentItem.type === 'user' &&
(currentItem.user_id === itemToDelete.id && currentItem.type === itemToDelete.type)) {
if (currentItem.type !== itemToDelete.type) {
continue;
}
if (currentItem.type === LEVEL_TYPES.USER && currentItem.user_id === itemToDelete.id) {
index = i;
} else if (currentItem.type === LEVEL_TYPES.ROLE && currentItem.access_level === itemToDelete.id) {
index = i;
} else if (currentItem.type === 'role' &&
(currentItem.access_level === itemToDelete.id && currentItem.type === itemToDelete.type)) {
} else if (currentItem.type === LEVEL_TYPES.GROUP && currentItem.group_id === itemToDelete.id) {
index = i;
}
if (index) { break; }
if (index > -1) { break; }
}
// 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;
......@@ -182,63 +251,123 @@
label.push(this.defaultLabel);
}
return label.join(' and ');
this.$dropdown.find('.dropdown-toggle-text').toggleClass('is-default', !currentItems.length);
return label.join(', ');
}
getData(query, callback) {
this.getUsers(query).done((response) => {
let data = this.consolidateData(response);
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));
});
}
callback(data);
}).error(() => {
new Flash('Failed to load users.');
});
}
consolidateData(response, callback) {
let users;
let mergeAccessLevels;
let consolidatedData;
consolidateData(usersResponse, groupsResponse) {
let consolidatedData = [];
let map = [];
let roles = [];
let selectedUsers = [];
let unselectedUsers = [];
let groups = [];
let selectedItems = this.getSelectedItems();
mergeAccessLevels = this.accessLevelsData.map((level) => {
level.type = 'role';
return level;
// 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
*/
groups = groupsResponse.map((group) => {
group.type = LEVEL_TYPES.GROUP;
return group;
});
let aggregate = [];
let map = [];
/*
* Build roles
*/
roles = this.accessLevelsData.map((level) => {
level.type = LEVEL_TYPES.ROLE;
return level;
});
/*
* Build users
*/
for (let x = 0; x < selectedItems.length; x++) {
let current = selectedItems[x];
if (current.type !== 'user') { continue; }
map.push(current.user_id);
if (current.type !== LEVEL_TYPES.USER) { continue; }
aggregate.push({
// Collect selected users
selectedUsers.push({
id: current.user_id,
name: current.name,
username: current.username,
avatar_url: current.avatar_url,
type: 'user'
type: LEVEL_TYPES.USER
});
// Save identifiers for easy-checking more later
map.push(LEVEL_TYPES.USER + current.user_id);
}
for (let i = 0; i < response.length; i++) {
let x = response[i];
// Has to be checked against server response
// because the selected item can be in filter results
for (let i = 0; i < usersResponse.length; i++) {
let u = usersResponse[i];
// Add is it has not been added
if (map.indexOf(x.id) === -1){
x.type = 'user';
aggregate.push(x);
if (map.indexOf(LEVEL_TYPES.USER + u.id) === -1){
u.type = LEVEL_TYPES.USER;
unselectedUsers.push(u);
}
}
if (groups.length) {
consolidatedData =consolidatedData.concat(groups);
}
consolidatedData = mergeAccessLevels;
if (roles.length) {
if (groups.length) {
consolidatedData = consolidatedData.concat(['divider']);
}
if (aggregate.length) {
consolidatedData = mergeAccessLevels.concat(['divider'], aggregate);
consolidatedData = consolidatedData.concat(roles);
}
if (selectedUsers.length) {
consolidatedData = consolidatedData.concat(['divider'], selectedUsers);
}
if (unselectedUsers.length) {
if (!selectedUsers.length) {
consolidatedData = consolidatedData.concat(['divider']);
}
consolidatedData = consolidatedData.concat(unselectedUsers);
}
return consolidatedData;
......@@ -258,6 +387,16 @@
});
}
getGroups(query) {
return $.ajax({
dataType: 'json',
url: this.buildUrl(this.groupsPath),
data: {
project_id: gon.current_project_id
}
});
}
buildUrl(url) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root.replace(/\/$/, '') + url;
......@@ -271,18 +410,22 @@
// Dectect if the current item is already saved so we can add
// the `is-active` class so the item looks as marked
if (item.type === 'user') {
if (item.type === LEVEL_TYPES.USER) {
criteria = { user_id: item.id };
} else if (item.type === 'role') {
} else if (item.type === LEVEL_TYPES.ROLE) {
criteria = { access_level: item.id };
} else if (item.type === LEVEL_TYPES.GROUP) {
criteria = { group_id: item.id };
}
isActive = _.findWhere(this.getSelectedItems(), criteria) ? 'is-active' : '';
if (item.type === 'user') {
if (item.type === LEVEL_TYPES.USER) {
return this.userRowHtml(item, isActive);
} else if (item.type === 'role') {
} else if (item.type === LEVEL_TYPES.ROLE) {
return this.roleRowHtml(item, isActive);
} else if (item.type === LEVEL_TYPES.GROUP) {
return this.groupRowHtml(item, isActive);
}
}
......@@ -293,8 +436,15 @@
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${avatarHtml} ${nameHtml} ${usernameHtml}</a></li>`;
}
groupRowHtml(group, isActive) {
const avatarHtml = group.avatar_url ? `<img src='${group.avatar_url}' class='avatar avatar-inline' width='30'>` : '';
const nameHtml = `<strong class='dropdown-menu-group-full-name'>${group.name}</strong>`;
const groupnameHtml = `<span class='dropdown-menu-group-groupname'>${group.name}</span>`;
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${avatarHtml} ${nameHtml} ${groupnameHtml}</a></li>`;
}
roleRowHtml(role, isActive) {
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${role.text}</a></li>`;
return `<li><a href='#' class='${isActive ? 'is-active' : ''} item-${role.type}'>${role.text}</a></li>`;
}
}
......
......@@ -6,6 +6,12 @@
PUSH: 'push_access_levels',
};
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchCreate = class {
constructor() {
this.$wrap = this.$form = $('#new_protected_branch');
......@@ -72,14 +78,18 @@
for (let i = 0; i < selectedItems.length; i++) {
let current = selectedItems[i];
if (current.type === 'user') {
if (current.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: selectedItems[i].user_id
});
} else if (current.type === 'role') {
} else if (current.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: selectedItems[i].access_level
});
} else if (current.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: selectedItems[i].group_id
});
}
}
......
......@@ -6,6 +6,12 @@
PUSH: 'push_access_levels',
};
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchEdit = class {
constructor(options) {
this.$wraps = {};
......@@ -21,6 +27,7 @@
}
buildDropdowns() {
// Allowed to merge dropdown
this['merge_access_levels_dropdown'] = new gl.ProtectedBranchAccessDropdown({
accessLevel: ACCESS_LEVELS.MERGE,
......@@ -95,25 +102,33 @@
let currentItem = items[i];
if (currentItem.user_id) {
// Solo haciendo esto solo para usuarios por ahora
// obtenemos la data más actual de los items seleccionados
// Do this only for users for now
// get the current data for selected items
let selectedItems = this[dropdownName].getSelectedItems();
let currentSelectedItem = _.findWhere(selectedItems, { user_id: currentItem.user_id });
itemToAdd = {
id: currentItem.id,
user_id: currentItem.user_id,
type: 'user',
type: LEVEL_TYPES.USER,
persisted: true,
name: currentSelectedItem.name,
username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url
}
} else if (currentItem.group_id) {
itemToAdd = {
id: currentItem.id,
group_id: currentItem.group_id,
type: LEVEL_TYPES.GROUP,
persisted: true
};
} else {
itemToAdd = {
id: currentItem.id,
access_level: currentItem.access_level,
type: 'role',
type: LEVEL_TYPES.ROLE,
persisted: true
}
}
......
// Modified version of `UsersSelect` for use with access selection for protected branches.
//
// - Selections are sent via AJAX if `saveOnSelect` is `true`
// - If `saveOnSelect` is `false`, the dropdown element must have a `field-name` data
// attribute. The DOM must contain two fields - "#{field-name}[access_level]" and "#{field_name}[user_id]"
// where the selections will be stored.
class ProtectedBranchesAccessSelect {
constructor(container, saveOnSelect, selectDefault) {
this.container = container;
this.saveOnSelect = saveOnSelect;
this.selectDefault = selectDefault;
this.usersPath = "/autocomplete/users.json";
this.setupDropdown(".allowed-to-merge", gon.merge_access_levels, gon.selected_merge_access_levels);
this.setupDropdown(".allowed-to-push", gon.push_access_levels, gon.selected_push_access_levels);
}
setupDropdown(className, accessLevels, selectedAccessLevels) {
this.container.find(className).each((i, element) => {
var dropdown = $(element).glDropdown({
clicked: _.chain(this.onSelect).partial(element).bind(this).value(),
data: (term, callback) => {
this.getUsers(term, (users) => {
users = _(users).map((user) => _(user).extend({ type: "user" }));
accessLevels = _(accessLevels).map((accessLevel) => _(accessLevel).extend({ type: "role" }));
var accessLevelsWithUsers = accessLevels.concat("divider", users);
callback(_(accessLevelsWithUsers).reject((item) => _.contains(selectedAccessLevels, item.id)));
});
},
filterable: true,
filterRemote: true,
search: { fields: ['name', 'username'] },
selectable: true,
toggleLabel: (selected) => $(element).data('default-label'),
renderRow: (user) => {
if (user.before_divider != null) {
return "<li> <a href='#'>" + user.text + " </a> </li>";
}
var username = user.username ? "@" + user.username : null;
var avatar = user.avatar_url ? user.avatar_url : false;
var img = avatar ? "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />" : '';
var listWithName = "<li> <a href='#' class='dropdown-menu-user-link'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
var listWithUserName = username ? "<span class='dropdown-menu-user-username'> " + username + " </span>" : '';
var listClosingTags = "</a> </li>";
return listWithName + listWithUserName + listClosingTags;
}
});
if (this.selectDefault) {
$(dropdown).find('.dropdown-toggle-text').text(accessLevels[0].text);
}
});
}
onSelect(dropdown, selected, element, e) {
$(dropdown).find('.dropdown-toggle-text').text(selected.text || selected.name);
var access_level = selected.type == 'user' ? 40 : selected.id;
var user_id = selected.type == 'user' ? selected.id : null;
if (this.saveOnSelect) {
$.ajax({
type: "POST",
url: $(dropdown).data('url'),
dataType: "json",
data: {
_method: 'PATCH',
id: $(dropdown).data('id'),
protected_branch: {
["" + ($(dropdown).data('type')) + "_attributes"]: [{
access_level: access_level,
user_id: user_id
}]
}
},
success: function() {
var row;
row = $(e.target);
row.closest('tr').effect('highlight');
row.closest('td').find('.access-levels-list').append("<li>" + selected.name + "</li>");
location.reload();
},
error: function() {
new Flash("Failed to update branch!", "alert");
}
});
} else {
var fieldName = $(dropdown).data('field-name');
$("input[name='" + fieldName + "[access_level]']").val(access_level);
$("input[name='" + fieldName + "[user_id]']").val(user_id);
}
}
getUsers(query, callback) {
var url = this.buildUrl(this.usersPath);
return $.ajax({
url: url,
data: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true
},
dataType: "json"
}).done(function(users) {
callback(users);
});
}
buildUrl(url) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root.replace(/\/$/, '') + url;
}
return url;
}
}
class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users]
before_action :load_project, only: [:users]
before_action :load_project, only: [:users, :project_groups]
before_action :find_users, only: [:users]
def users
......@@ -28,6 +28,10 @@ class AutocompleteController < ApplicationController
render json: @users, only: [:name, :username, :id], methods: [:avatar_url]
end
def project_groups
render json: @project.invited_groups, only: [:id, :name], methods: [:avatar_url]
end
def user
@user = User.find(params[:id])
render json: @user, only: [:name, :username, :id], methods: [:avatar_url]
......
......@@ -59,8 +59,8 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def protected_branch_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: [:access_level, :id, :user_id, :_destroy],
push_access_levels_attributes: [:access_level, :id, :user_id, :_destroy])
merge_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id],
push_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id])
end
def load_protected_branches
......
......@@ -41,6 +41,8 @@ module BranchesHelper
name: level.user.name,
avatar_url: level.user.avatar_url
}
elsif level.type == :group
{ id: level.id, type: level.type, group_id: level.group_id }
else
{ id: level.id, type: level.type, access_level: level.access_level }
end
......
......@@ -2,13 +2,19 @@ module ProtectedBranchAccess
extend ActiveSupport::Concern
included do
validates :user_id, uniqueness: { scope: :protected_branch, allow_nil: true }
validates :access_level, uniqueness: { scope: :protected_branch, unless: :user_id?, conditions: -> { where(user_id: nil) } }
validates_uniqueness_of :group_id, scope: :protected_branch, allow_nil: true
validates_uniqueness_of :user_id, scope: :protected_branch, allow_nil: true
validates_uniqueness_of :access_level,
scope: :protected_branch,
unless: Proc.new { |access_level| access_level.user_id? || access_level.group_id? },
conditions: -> { where(user_id: nil, group_id: nil) }
end
def type
if self.user.present?
:user
elsif self.group.present?
:group
else
:role
end
......@@ -16,6 +22,7 @@ module ProtectedBranchAccess
def humanize
return self.user.name if self.user.present?
return self.group.name if self.group.present?
self.class.human_access_levels[self.access_level]
end
......
......@@ -16,6 +16,8 @@ class ProjectGroupLink < ActiveRecord::Base
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group
before_destroy :delete_branch_protection
def self.access_options
Gitlab::Access.options
end
......@@ -35,4 +37,11 @@ class ProjectGroupLink < ActiveRecord::Base
errors.add(:base, "Project cannot be shared with the project it is in.")
end
end
def delete_branch_protection
if group.present? && project.present?
project.protected_branches.merge_access_by_group(group).destroy_all
project.protected_branches.push_access_by_group(group).destroy_all
end
end
end
......@@ -22,6 +22,14 @@ class ProtectedBranch < ActiveRecord::Base
# 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)) }
def commit
project.commit(self.name)
end
......
......@@ -3,6 +3,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
belongs_to :protected_branch
belongs_to :user
belongs_to :group
delegate :project, to: :protected_branch
......@@ -10,6 +11,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
Gitlab::Access::DEVELOPER] }
scope :by_user, -> (user) { where(user: user ) }
scope :by_group, -> (group) { where(group: group ) }
def self.human_access_levels
{
......@@ -21,6 +23,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
def check_access(user)
return true if user.is_admin?
return user.id == self.user_id if self.user.present?
return group.users.exists?(user.id) if self.group.present?
project.team.max_member_access(user.id) >= access_level
end
......
......@@ -3,6 +3,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
belongs_to :protected_branch
belongs_to :user
belongs_to :group
delegate :project, to: :protected_branch
......@@ -11,6 +12,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
Gitlab::Access::NO_ACCESS] }
scope :by_user, -> (user) { where(user: user ) }
scope :by_group, -> (group) { where(group: group ) }
def self.human_access_levels
{
......@@ -24,6 +26,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
return false if access_level == Gitlab::Access::NO_ACCESS
return true if user.is_admin?
return user.id == self.user_id if self.user.present?
return group.users.exists?(user.id) if self.group.present?
project.team.max_member_access(user.id) >= access_level
end
......
......@@ -3,7 +3,7 @@
%div{ class: "#{input_basic_name}-container" }
- if access_levels.present?
- dropdown_label = [pluralize(level_frequencies[:role], 'role'), pluralize(level_frequencies[:user], 'user')].to_sentence
- 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) } })
......@@ -36,6 +36,10 @@
options: { toggle_class: 'js-allowed-to-push js-multiselect wide',
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
data: { input_id: 'push_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
- page_title "Protected branches"
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('protected_branches/protected_branches_bundle.js')
.row.prepend-top-default.append-bottom-default
.col-lg-3
......
......@@ -90,6 +90,7 @@ module Gitlab
config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js"
config.assets.precompile << "protected_branches/protected_branches_bundle.js"
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
config.assets.precompile << "boards/boards_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
......
......@@ -40,6 +40,7 @@ Rails.application.routes.draw do
get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/project_groups' => 'autocomplete#project_groups'
# Emojis
resources :emojis, only: :index
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddGroupIdColumnsToProtectedBranchAccessLevels < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = true
DOWNTIME_REASON = "This migrations adds two foreign keys, and so requires downtime."
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :protected_branch_merge_access_levels, :group_id, :integer
add_foreign_key :protected_branch_merge_access_levels, :namespaces, column: :group_id
add_column :protected_branch_push_access_levels, :group_id, :integer
add_foreign_key :protected_branch_push_access_levels, :namespaces, column: :group_id
end
end
......@@ -1049,6 +1049,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
t.integer "group_id"
end
add_index "protected_branch_merge_access_levels", ["protected_branch_id"], name: "index_protected_branch_merge_access", using: :btree
......@@ -1060,6 +1061,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
t.integer "group_id"
end
add_index "protected_branch_push_access_levels", ["protected_branch_id"], name: "index_protected_branch_push_access", using: :btree
......@@ -1405,8 +1407,10 @@ ActiveRecord::Schema.define(version: 20161007133303) do
add_foreign_key "path_locks", "projects"
add_foreign_key "path_locks", "users"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "protected_branch_merge_access_levels", "namespaces", column: "group_id"
add_foreign_key "protected_branch_merge_access_levels", "protected_branches"
add_foreign_key "protected_branch_merge_access_levels", "users"
add_foreign_key "protected_branch_push_access_levels", "namespaces", column: "group_id"
add_foreign_key "protected_branch_push_access_levels", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "users"
add_foreign_key "remote_mirrors", "projects"
......
......@@ -338,4 +338,44 @@ describe AutocompleteController do
end
end
end
context "groups" do
let(:matching_group) { create(:group) }
let(:non_matching_group) { create(:group) }
context "while fetching all groups belonging to a project" do
before do
project.team << [user, :developer]
project.invited_groups << matching_group
sign_in(user)
get(:project_groups, project_id: project.id)
end
let(:body) { JSON.parse(response.body) }
it { expect(body).to be_kind_of(Array) }
it { expect(body.size).to eq 1 }
it { expect(body.first.values_at('id', 'name')).to eq [matching_group.id, matching_group.name] }
end
context "while fetching all groups belonging to a project the current user cannot access" do
before do
project.invited_groups << matching_group
sign_in(user)
get(:project_groups, project_id: project.id)
end
it { expect(response).to be_not_found }
end
context "while fetching all groups belonging to an invalid project ID" do
before do
project.invited_groups << matching_group
sign_in(user)
get(:project_groups, project_id: 'invalid')
end
it { expect(response).to be_not_found }
end
end
end
......@@ -11,6 +11,9 @@ FactoryGirl.define do
transient do
authorize_user_to_push nil
authorize_user_to_merge nil
authorize_group_to_push nil
authorize_group_to_merge nil
end
trait :remove_default_access_levels do
......@@ -47,6 +50,9 @@ FactoryGirl.define do
after(:create) do |protected_branch, evaluator|
protected_branch.push_access_levels.create!(user: evaluator.authorize_user_to_push) if evaluator.authorize_user_to_push
protected_branch.merge_access_levels.create!(user: evaluator.authorize_user_to_merge) if evaluator.authorize_user_to_merge
protected_branch.push_access_levels.create!(group: evaluator.authorize_group_to_push) if evaluator.authorize_group_to_push
protected_branch.merge_access_levels.create!(group: evaluator.authorize_group_to_merge) if evaluator.authorize_group_to_merge
end
end
end
FactoryGirl.define do
factory :protected_branch_merge_access_level, class: ProtectedBranch::MergeAccessLevel do
user nil
group nil
protected_branch
access_level { Gitlab::Access::DEVELOPER }
end
......
FactoryGirl.define do
factory :protected_branch_push_access_level, class: ProtectedBranch::PushAccessLevel do
user nil
group nil
protected_branch
access_level { Gitlab::Access::DEVELOPER }
end
......
......@@ -2,16 +2,22 @@ RSpec.shared_examples "protected branches > access control > EE" do
[['merge', ProtectedBranch::MergeAccessLevel], ['push', ProtectedBranch::PushAccessLevel]].each do |git_operation, access_level_class|
# Need to set a default for the `git_operation` access level that _isn't_ being tested
other_git_operation = git_operation == 'merge' ? 'push' : 'merge'
roles = git_operation == 'merge' ? access_level_class.human_access_levels : access_level_class.human_access_levels.except(0)
it "allows creating protected branches that roles and users can #{git_operation} to" do
users = create_list(:user, 5)
let(:users) { create_list(:user, 5) }
let(:groups) { create_list(:group, 5) }
before do
users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
groups.each { |group| project.project_group_links.create(group: group, group_access: Gitlab::Access::DEVELOPER) }
end
it "allows creating protected branches that roles, users, and groups can #{git_operation} to" do
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
set_allowed_to(git_operation, users.map(&:name))
set_allowed_to(git_operation, groups.map(&:name))
set_allowed_to(git_operation, roles.values)
set_allowed_to(other_git_operation)
......@@ -21,13 +27,10 @@ RSpec.shared_examples "protected branches > access control > EE" do
expect(ProtectedBranch.count).to eq(1)
roles.each { |(access_type_id, _)| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:access_level)).to include(access_type_id) }
users.each { |user| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:user_id)).to include(user.id) }
groups.each { |group| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:group_id)).to include(group.id) }
end
it "allows updating protected branches that roles and users can #{git_operation} to" do
users = create_list(:user, 5)
users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
it "allows updating protected branches so that roles and users can #{git_operation} to it" do
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
set_allowed_to('merge')
......@@ -37,6 +40,7 @@ RSpec.shared_examples "protected branches > access control > EE" do
within(".js-protected-branch-edit-form") do
set_allowed_to(git_operation, users.map(&:name))
set_allowed_to(git_operation, groups.map(&:name))
set_allowed_to(git_operation, roles.values)
end
......@@ -45,12 +49,35 @@ RSpec.shared_examples "protected branches > access control > EE" do
expect(ProtectedBranch.count).to eq(1)
roles.each { |(access_type_id, _)| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:access_level)).to include(access_type_id) }
users.each { |user| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:user_id)).to include(user.id) }
groups.each { |group| expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:group_id)).to include(group.id) }
end
it "allows updating protected branches so that roles and users cannot #{git_operation} to it" do
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
users.each { |user| set_allowed_to(git_operation, user.name) }
roles.each { |(_, access_type_name)| set_allowed_to(git_operation, access_type_name) }
groups.each { |group| set_allowed_to(git_operation, group.name) }
set_allowed_to(other_git_operation)
click_on "Protect"
within(".js-protected-branch-edit-form") do
users.each { |user| set_allowed_to(git_operation, user.name) }
groups.each { |group| set_allowed_to(git_operation, group.name) }
roles.each { |(_, access_type_name)| set_allowed_to(git_operation, access_type_name) }
end
wait_for_ajax
expect(ProtectedBranch.count).to eq(1)
expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym)).to be_empty
end
it "prepends selected users that can #{git_operation} to" do
users = create_list(:user, 21)
users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project)
......@@ -69,7 +96,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
click_on users.last.name
find(".js-allowed-to-#{git_operation}").click # close
end
wait_for_ajax
# Verify the user is appended in the dropdown
......@@ -81,4 +107,48 @@ RSpec.shared_examples "protected branches > access control > EE" do
expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:user_id)).to include(users.last.id)
end
end
context 'When updating a protected branch' do
it 'discards other roles when choosing "No one"' do
roles = ProtectedBranch::PushAccessLevel.human_access_levels.except(0)
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('fix')
set_allowed_to('merge')
set_allowed_to('push', roles.values)
click_on "Protect"
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(0)
within(".js-protected-branch-edit-form") do
set_allowed_to('push', 'No one')
end
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(0)
end
end
context 'When creating a protected branch' do
it 'discards other roles when choosing "No one"' do
roles = ProtectedBranch::PushAccessLevel.human_access_levels.except(0)
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
set_allowed_to('merge')
set_allowed_to('push', ProtectedBranch::PushAccessLevel.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(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(0)
end
end
end
require 'spec_helper'
Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f }
feature 'Projected Branches', feature: true, js: true do
feature 'Protected Branches', feature: true, js: true do
include WaitForAjax
let(:user) { create(:user, :admin) }
......
......@@ -201,6 +201,7 @@ describe Gitlab::GitAccess, lib: true do
end
end
# Run permission checks for a user
def self.run_permission_checks(permissions_matrix)
permissions_matrix.keys.each do |role|
describe "#{role} access" do
......@@ -210,6 +211,27 @@ describe Gitlab::GitAccess, lib: true do
else
project.team << [user, role]
end
permissions_matrix[role].each do |action, allowed|
context action do
subject { access.push_access_check(changes[action]) }
it { expect(subject.allowed?).to allowed ? be_truthy : be_falsey }
end
end
end
end
end
end
# Run permission checks for a group
def self.run_group_permission_checks(permissions_matrix)
permissions_matrix.keys.each do |role|
describe "#{role} access" do
before do
project.project_group_links.create(
group: group, group_access: Gitlab::Access.sym_options[role]
)
end
permissions_matrix[role].each do |action, allowed|
......@@ -326,6 +348,7 @@ describe Gitlab::GitAccess, lib: true do
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
context "user-specific access control" do
context "when a specific user is allowed to push into the #{protected_branch_type} protected branch" do
let(:user) { create(:user) }
......@@ -365,6 +388,60 @@ describe Gitlab::GitAccess, lib: true do
guest: { push_protected_branch: false, merge_into_protected_branch: false },
reporter: { push_protected_branch: false, merge_into_protected_branch: false }))
end
end
context "group-specific access control" do
context "when a specific group is allowed to push into the #{protected_branch_type} protected branch" do
let(:user) { create(:user) }
let(:group) { create(:group) }
before do
group.add_master(user)
create(:protected_branch, :remove_default_access_levels, authorize_group_to_push: group, name: protected_branch_name, project: project)
end
permissions = permissions_matrix.except(:admin).deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true },
guest: { push_protected_branch: false, merge_into_protected_branch: false },
reporter: { push_protected_branch: false, merge_into_protected_branch: false })
run_group_permission_checks(permissions)
end
context "when a specific group is allowed to merge into the #{protected_branch_type} protected branch" do
let(:user) { create(:user) }
let(:group) { create(:group) }
before do
group.add_master(user)
create(:merge_request, source_project: project, source_branch: unprotected_branch, target_branch: 'feature', state: 'locked', in_progress_merge_commit_sha: merge_into_protected_branch)
create(:protected_branch, :remove_default_access_levels, authorize_group_to_merge: group, name: protected_branch_name, project: project)
end
permissions = permissions_matrix.except(:admin).deep_merge(master: { push_protected_branch: false, push_all: false, merge_into_protected_branch: true },
developer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: true },
guest: { push_protected_branch: false, merge_into_protected_branch: false },
reporter: { push_protected_branch: false, merge_into_protected_branch: false })
run_group_permission_checks(permissions)
end
context "when a specific group is allowed to push & merge into the #{protected_branch_type} protected branch" do
let(:user) { create(:user) }
let(:group) { create(:group) }
before do
group.add_master(user)
create(:merge_request, source_project: project, source_branch: unprotected_branch, target_branch: 'feature', state: 'locked', in_progress_merge_commit_sha: merge_into_protected_branch)
create(:protected_branch, :remove_default_access_levels, authorize_group_to_push: group, authorize_group_to_merge: group, name: protected_branch_name, project: project)
end
permissions = permissions_matrix.except(:admin).deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true },
guest: { push_protected_branch: false, merge_into_protected_branch: false },
reporter: { push_protected_branch: false, merge_into_protected_branch: false })
run_group_permission_checks(permissions)
end
end
context "when no one is allowed to push to the #{protected_branch_name} protected branch" do
before { create(:protected_branch, :remove_default_access_levels, :no_one_can_push, name: protected_branch_name, project: project) }
......
......@@ -112,9 +112,11 @@ protected_branches:
merge_access_levels:
- user
- protected_branch
- group
push_access_levels:
- user
- protected_branch
- group
project:
- taggings
- base_tags
......
......@@ -318,6 +318,7 @@ ProtectedBranch::MergeAccessLevel:
- created_at
- updated_at
- user_id
- group_id
ProtectedBranch::PushAccessLevel:
- id
- protected_branch_id
......@@ -325,6 +326,7 @@ ProtectedBranch::PushAccessLevel:
- created_at
- updated_at
- user_id
- group_id
AwardEmoji:
- id
- user_id
......
......@@ -38,6 +38,16 @@ describe ProtectedBranch, models: true do
expect(protected_branch).to be_valid
end
it "does not count a group-based #{human_association_name} with an `access_level` set" do
group = create(:group)
protected_branch = create(:protected_branch, :remove_default_access_levels)
protected_branch.send(association_name) << build(factory_name, group: group, access_level: Gitlab::Access::MASTER)
protected_branch.send(association_name) << build(factory_name, access_level: Gitlab::Access::MASTER)
expect(protected_branch).to be_valid
end
end
context "while checking uniqueness of a user-based #{human_association_name}" do
......@@ -65,6 +75,34 @@ describe ProtectedBranch, models: true do
expect(protected_branch).to be_valid
end
end
context "while checking uniqueness of a group-based #{human_association_name}" do
let(:group) { create(:group) }
it "allows a single #{human_association_name} for a group (per protected branch)" do
first_protected_branch = create(:protected_branch, :remove_default_access_levels)
second_protected_branch = create(:protected_branch, :remove_default_access_levels)
first_protected_branch.send(association_name) << build(factory_name, group: group)
second_protected_branch.send(association_name) << build(factory_name, group: group)
expect(first_protected_branch).to be_valid
expect(second_protected_branch).to be_valid
first_protected_branch.send(association_name) << build(factory_name, group: group)
expect(first_protected_branch).to be_invalid
expect(first_protected_branch.errors.full_messages.first).to match("group has already been taken")
end
it "ignores the `access_level` while validating a group-based #{human_association_name}" do
protected_branch = create(:protected_branch, :remove_default_access_levels)
protected_branch.send(association_name) << build(factory_name, access_level: Gitlab::Access::MASTER)
protected_branch.send(association_name) << build(factory_name, group: group, access_level: Gitlab::Access::MASTER)
expect(protected_branch).to be_valid
end
end
end
end
......
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