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. ...@@ -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 - Add user activity table and service to query for active users
- Fix 500 error updating mirror URLs for projects - Fix 500 error updating mirror URLs for projects
- Restrict protected branch access to specific groups !645
- Fix validations related to mirroring settings form. !773 - Fix validations related to mirroring settings form. !773
- Add multiple issue boards. !782 - Add multiple issue boards. !782
- Fix Git access panel for Wikis when Kerberos authentication is enabled (Borja Aparicio) - Fix Git access panel for Wikis when Kerberos authentication is enabled (Borja Aparicio)
......
...@@ -6,6 +6,12 @@ ...@@ -6,6 +6,12 @@
PUSH: 'push_access_levels', PUSH: 'push_access_levels',
}; };
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchCreate = class { gl.ProtectedBranchCreate = class {
constructor() { constructor() {
this.$wrap = this.$form = $('#new_protected_branch'); this.$wrap = this.$form = $('#new_protected_branch');
...@@ -72,14 +78,18 @@ ...@@ -72,14 +78,18 @@
for (let i = 0; i < selectedItems.length; i++) { for (let i = 0; i < selectedItems.length; i++) {
let current = selectedItems[i]; let current = selectedItems[i];
if (current.type === 'user') { if (current.type === LEVEL_TYPES.USER) {
levelAttributes.push({ levelAttributes.push({
user_id: selectedItems[i].user_id user_id: selectedItems[i].user_id
}); });
} else if (current.type === 'role') { } else if (current.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({ levelAttributes.push({
access_level: selectedItems[i].access_level 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 @@ ...@@ -6,6 +6,12 @@
PUSH: 'push_access_levels', PUSH: 'push_access_levels',
}; };
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchEdit = class { gl.ProtectedBranchEdit = class {
constructor(options) { constructor(options) {
this.$wraps = {}; this.$wraps = {};
...@@ -21,6 +27,7 @@ ...@@ -21,6 +27,7 @@
} }
buildDropdowns() { buildDropdowns() {
// Allowed to merge dropdown // Allowed to merge dropdown
this['merge_access_levels_dropdown'] = new gl.ProtectedBranchAccessDropdown({ this['merge_access_levels_dropdown'] = new gl.ProtectedBranchAccessDropdown({
accessLevel: ACCESS_LEVELS.MERGE, accessLevel: ACCESS_LEVELS.MERGE,
...@@ -95,25 +102,33 @@ ...@@ -95,25 +102,33 @@
let currentItem = items[i]; let currentItem = items[i];
if (currentItem.user_id) { 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 selectedItems = this[dropdownName].getSelectedItems();
let currentSelectedItem = _.findWhere(selectedItems, { user_id: currentItem.user_id }); let currentSelectedItem = _.findWhere(selectedItems, { user_id: currentItem.user_id });
itemToAdd = { itemToAdd = {
id: currentItem.id, id: currentItem.id,
user_id: currentItem.user_id, user_id: currentItem.user_id,
type: 'user', type: LEVEL_TYPES.USER,
persisted: true, persisted: true,
name: currentSelectedItem.name, name: currentSelectedItem.name,
username: currentSelectedItem.username, username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url 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 { } else {
itemToAdd = { itemToAdd = {
id: currentItem.id, id: currentItem.id,
access_level: currentItem.access_level, access_level: currentItem.access_level,
type: 'role', type: LEVEL_TYPES.ROLE,
persisted: true 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 class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users] 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] before_action :find_users, only: [:users]
def users def users
...@@ -28,6 +28,10 @@ class AutocompleteController < ApplicationController ...@@ -28,6 +28,10 @@ class AutocompleteController < ApplicationController
render json: @users, only: [:name, :username, :id], methods: [:avatar_url] render json: @users, only: [:name, :username, :id], methods: [:avatar_url]
end end
def project_groups
render json: @project.invited_groups, only: [:id, :name], methods: [:avatar_url]
end
def user def user
@user = User.find(params[:id]) @user = User.find(params[:id])
render json: @user, only: [:name, :username, :id], methods: [:avatar_url] render json: @user, only: [:name, :username, :id], methods: [:avatar_url]
......
...@@ -59,8 +59,8 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController ...@@ -59,8 +59,8 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def protected_branch_params def protected_branch_params
params.require(:protected_branch).permit(:name, params.require(:protected_branch).permit(:name,
merge_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]) push_access_levels_attributes: [:access_level, :id, :user_id, :_destroy, :group_id])
end end
def load_protected_branches def load_protected_branches
......
...@@ -41,6 +41,8 @@ module BranchesHelper ...@@ -41,6 +41,8 @@ module BranchesHelper
name: level.user.name, name: level.user.name,
avatar_url: level.user.avatar_url avatar_url: level.user.avatar_url
} }
elsif level.type == :group
{ id: level.id, type: level.type, group_id: level.group_id }
else else
{ id: level.id, type: level.type, access_level: level.access_level } { id: level.id, type: level.type, access_level: level.access_level }
end end
......
...@@ -2,13 +2,19 @@ module ProtectedBranchAccess ...@@ -2,13 +2,19 @@ module ProtectedBranchAccess
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
validates :user_id, uniqueness: { scope: :protected_branch, allow_nil: true } validates_uniqueness_of :group_id, scope: :protected_branch, allow_nil: true
validates :access_level, uniqueness: { scope: :protected_branch, unless: :user_id?, conditions: -> { where(user_id: nil) } } 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 end
def type def type
if self.user.present? if self.user.present?
:user :user
elsif self.group.present?
:group
else else
:role :role
end end
...@@ -16,6 +22,7 @@ module ProtectedBranchAccess ...@@ -16,6 +22,7 @@ module ProtectedBranchAccess
def humanize def humanize
return self.user.name if self.user.present? return self.user.name if self.user.present?
return self.group.name if self.group.present?
self.class.human_access_levels[self.access_level] self.class.human_access_levels[self.access_level]
end end
......
...@@ -16,6 +16,8 @@ class ProjectGroupLink < ActiveRecord::Base ...@@ -16,6 +16,8 @@ class ProjectGroupLink < ActiveRecord::Base
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group validate :different_group
before_destroy :delete_branch_protection
def self.access_options def self.access_options
Gitlab::Access.options Gitlab::Access.options
end end
...@@ -35,4 +37,11 @@ class ProjectGroupLink < ActiveRecord::Base ...@@ -35,4 +37,11 @@ class ProjectGroupLink < ActiveRecord::Base
errors.add(:base, "Project cannot be shared with the project it is in.") errors.add(:base, "Project cannot be shared with the project it is in.")
end end
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 end
...@@ -22,6 +22,14 @@ class ProtectedBranch < ActiveRecord::Base ...@@ -22,6 +22,14 @@ class ProtectedBranch < ActiveRecord::Base
# access to the given user. # 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)) } 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 def commit
project.commit(self.name) project.commit(self.name)
end end
......
...@@ -3,6 +3,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base ...@@ -3,6 +3,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
belongs_to :protected_branch belongs_to :protected_branch
belongs_to :user belongs_to :user
belongs_to :group
delegate :project, to: :protected_branch delegate :project, to: :protected_branch
...@@ -10,6 +11,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base ...@@ -10,6 +11,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
Gitlab::Access::DEVELOPER] } Gitlab::Access::DEVELOPER] }
scope :by_user, -> (user) { where(user: user ) } scope :by_user, -> (user) { where(user: user ) }
scope :by_group, -> (group) { where(group: group ) }
def self.human_access_levels def self.human_access_levels
{ {
...@@ -21,6 +23,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base ...@@ -21,6 +23,7 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
def check_access(user) def check_access(user)
return true if user.is_admin? return true if user.is_admin?
return user.id == self.user_id if self.user.present? 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 project.team.max_member_access(user.id) >= access_level
end end
......
...@@ -3,6 +3,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base ...@@ -3,6 +3,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
belongs_to :protected_branch belongs_to :protected_branch
belongs_to :user belongs_to :user
belongs_to :group
delegate :project, to: :protected_branch delegate :project, to: :protected_branch
...@@ -11,6 +12,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base ...@@ -11,6 +12,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
Gitlab::Access::NO_ACCESS] } Gitlab::Access::NO_ACCESS] }
scope :by_user, -> (user) { where(user: user ) } scope :by_user, -> (user) { where(user: user ) }
scope :by_group, -> (group) { where(group: group ) }
def self.human_access_levels def self.human_access_levels
{ {
...@@ -24,6 +26,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base ...@@ -24,6 +26,7 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
return false if access_level == Gitlab::Access::NO_ACCESS return false if access_level == Gitlab::Access::NO_ACCESS
return true if user.is_admin? return true if user.is_admin?
return user.id == self.user_id if self.user.present? 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 project.team.max_member_access(user.id) >= access_level
end end
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
%div{ class: "#{input_basic_name}-container" } %div{ class: "#{input_basic_name}-container" }
- if access_levels.present? - 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, = 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) } }) data: { default_label: default_label, preselected_items: access_levels_data(access_levels) } })
...@@ -36,6 +36,10 @@ ...@@ -36,6 +36,10 @@
options: { toggle_class: 'js-allowed-to-push js-multiselect wide', options: { toggle_class: 'js-allowed-to-push js-multiselect wide',
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
data: { input_id: 'push_access_levels_attributes', default_label: 'Select' } }) 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 .panel-footer
= f.submit 'Protect', class: 'btn-create btn', disabled: true = f.submit 'Protect', class: 'btn-create btn', disabled: true
- page_title "Protected branches" - 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 .row.prepend-top-default.append-bottom-default
.col-lg-3 .col-lg-3
......
...@@ -90,6 +90,7 @@ module Gitlab ...@@ -90,6 +90,7 @@ module Gitlab
config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_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 << "diff_notes/diff_notes_bundle.js"
config.assets.precompile << "boards/boards_bundle.js" config.assets.precompile << "boards/boards_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js" config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
......
...@@ -40,6 +40,7 @@ Rails.application.routes.draw do ...@@ -40,6 +40,7 @@ Rails.application.routes.draw do
get '/autocomplete/users' => 'autocomplete#users' get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user' get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects' get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/project_groups' => 'autocomplete#project_groups'
# Emojis # Emojis
resources :emojis, only: :index 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 ...@@ -1049,6 +1049,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id" t.integer "user_id"
t.integer "group_id"
end end
add_index "protected_branch_merge_access_levels", ["protected_branch_id"], name: "index_protected_branch_merge_access", using: :btree 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 ...@@ -1060,6 +1061,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id" t.integer "user_id"
t.integer "group_id"
end end
add_index "protected_branch_push_access_levels", ["protected_branch_id"], name: "index_protected_branch_push_access", using: :btree 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 ...@@ -1405,8 +1407,10 @@ ActiveRecord::Schema.define(version: 20161007133303) do
add_foreign_key "path_locks", "projects" add_foreign_key "path_locks", "projects"
add_foreign_key "path_locks", "users" add_foreign_key "path_locks", "users"
add_foreign_key "personal_access_tokens", "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", "protected_branches"
add_foreign_key "protected_branch_merge_access_levels", "users" 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", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "users" add_foreign_key "protected_branch_push_access_levels", "users"
add_foreign_key "remote_mirrors", "projects" add_foreign_key "remote_mirrors", "projects"
......
...@@ -338,4 +338,44 @@ describe AutocompleteController do ...@@ -338,4 +338,44 @@ describe AutocompleteController do
end end
end 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 end
...@@ -11,6 +11,9 @@ FactoryGirl.define do ...@@ -11,6 +11,9 @@ FactoryGirl.define do
transient do transient do
authorize_user_to_push nil authorize_user_to_push nil
authorize_user_to_merge nil authorize_user_to_merge nil
authorize_group_to_push nil
authorize_group_to_merge nil
end end
trait :remove_default_access_levels do trait :remove_default_access_levels do
...@@ -47,6 +50,9 @@ FactoryGirl.define do ...@@ -47,6 +50,9 @@ FactoryGirl.define do
after(:create) do |protected_branch, evaluator| 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.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.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 end
end end
FactoryGirl.define do FactoryGirl.define do
factory :protected_branch_merge_access_level, class: ProtectedBranch::MergeAccessLevel do factory :protected_branch_merge_access_level, class: ProtectedBranch::MergeAccessLevel do
user nil user nil
group nil
protected_branch protected_branch
access_level { Gitlab::Access::DEVELOPER } access_level { Gitlab::Access::DEVELOPER }
end end
......
FactoryGirl.define do FactoryGirl.define do
factory :protected_branch_push_access_level, class: ProtectedBranch::PushAccessLevel do factory :protected_branch_push_access_level, class: ProtectedBranch::PushAccessLevel do
user nil user nil
group nil
protected_branch protected_branch
access_level { Gitlab::Access::DEVELOPER } access_level { Gitlab::Access::DEVELOPER }
end end
......
...@@ -2,16 +2,22 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -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| [['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 # Need to set a default for the `git_operation` access level that _isn't_ being tested
other_git_operation = git_operation == 'merge' ? 'push' : 'merge' 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 let(:users) { create_list(:user, 5) }
users = create_list(:user, 5) let(:groups) { create_list(:group, 5) }
before do
users.each { |user| project.team << [user, :developer] } 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) visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master') set_protected_branch_name('master')
set_allowed_to(git_operation, users.map(&:name)) 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(git_operation, roles.values)
set_allowed_to(other_git_operation) set_allowed_to(other_git_operation)
...@@ -21,13 +27,10 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -21,13 +27,10 @@ RSpec.shared_examples "protected branches > access control > EE" do
expect(ProtectedBranch.count).to eq(1) 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) } 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) } 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 end
it "allows updating protected branches that roles and users can #{git_operation} to" do it "allows updating protected branches so that roles and users can #{git_operation} to it" do
users = create_list(:user, 5)
users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project) visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master') set_protected_branch_name('master')
set_allowed_to('merge') set_allowed_to('merge')
...@@ -37,6 +40,7 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -37,6 +40,7 @@ RSpec.shared_examples "protected branches > access control > EE" do
within(".js-protected-branch-edit-form") do within(".js-protected-branch-edit-form") do
set_allowed_to(git_operation, users.map(&:name)) 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(git_operation, roles.values)
end end
...@@ -45,12 +49,35 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -45,12 +49,35 @@ RSpec.shared_examples "protected branches > access control > EE" do
expect(ProtectedBranch.count).to eq(1) 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) } 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) } 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 end
it "prepends selected users that can #{git_operation} to" do it "prepends selected users that can #{git_operation} to" do
users = create_list(:user, 21) users = create_list(:user, 21)
users.each { |user| project.team << [user, :developer] } users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project) visit namespace_project_protected_branches_path(project.namespace, project)
...@@ -69,7 +96,6 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -69,7 +96,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
click_on users.last.name click_on users.last.name
find(".js-allowed-to-#{git_operation}").click # close find(".js-allowed-to-#{git_operation}").click # close
end end
wait_for_ajax wait_for_ajax
# Verify the user is appended in the dropdown # Verify the user is appended in the dropdown
...@@ -81,4 +107,48 @@ RSpec.shared_examples "protected branches > access control > EE" do ...@@ -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) expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:user_id)).to include(users.last.id)
end end
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 end
require 'spec_helper' require 'spec_helper'
Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f } 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 include WaitForAjax
let(:user) { create(:user, :admin) } let(:user) { create(:user, :admin) }
......
...@@ -201,6 +201,7 @@ describe Gitlab::GitAccess, lib: true do ...@@ -201,6 +201,7 @@ describe Gitlab::GitAccess, lib: true do
end end
end end
# Run permission checks for a user
def self.run_permission_checks(permissions_matrix) def self.run_permission_checks(permissions_matrix)
permissions_matrix.keys.each do |role| permissions_matrix.keys.each do |role|
describe "#{role} access" do describe "#{role} access" do
...@@ -210,6 +211,27 @@ describe Gitlab::GitAccess, lib: true do ...@@ -210,6 +211,27 @@ describe Gitlab::GitAccess, lib: true do
else else
project.team << [user, role] project.team << [user, role]
end 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 end
permissions_matrix[role].each do |action, allowed| permissions_matrix[role].each do |action, allowed|
...@@ -326,6 +348,7 @@ describe Gitlab::GitAccess, lib: true do ...@@ -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 })) run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end end
context "user-specific access control" do
context "when a specific user is allowed to push into the #{protected_branch_type} protected branch" do context "when a specific user is allowed to push into the #{protected_branch_type} protected branch" do
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -365,6 +388,60 @@ describe Gitlab::GitAccess, lib: true do ...@@ -365,6 +388,60 @@ describe Gitlab::GitAccess, lib: true do
guest: { push_protected_branch: false, merge_into_protected_branch: false }, guest: { push_protected_branch: false, merge_into_protected_branch: false },
reporter: { push_protected_branch: false, merge_into_protected_branch: false })) reporter: { push_protected_branch: false, merge_into_protected_branch: false }))
end 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 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) } before { create(:protected_branch, :remove_default_access_levels, :no_one_can_push, name: protected_branch_name, project: project) }
......
...@@ -112,9 +112,11 @@ protected_branches: ...@@ -112,9 +112,11 @@ protected_branches:
merge_access_levels: merge_access_levels:
- user - user
- protected_branch - protected_branch
- group
push_access_levels: push_access_levels:
- user - user
- protected_branch - protected_branch
- group
project: project:
- taggings - taggings
- base_tags - base_tags
......
...@@ -318,6 +318,7 @@ ProtectedBranch::MergeAccessLevel: ...@@ -318,6 +318,7 @@ ProtectedBranch::MergeAccessLevel:
- created_at - created_at
- updated_at - updated_at
- user_id - user_id
- group_id
ProtectedBranch::PushAccessLevel: ProtectedBranch::PushAccessLevel:
- id - id
- protected_branch_id - protected_branch_id
...@@ -325,6 +326,7 @@ ProtectedBranch::PushAccessLevel: ...@@ -325,6 +326,7 @@ ProtectedBranch::PushAccessLevel:
- created_at - created_at
- updated_at - updated_at
- user_id - user_id
- group_id
AwardEmoji: AwardEmoji:
- id - id
- user_id - user_id
......
...@@ -38,6 +38,16 @@ describe ProtectedBranch, models: true do ...@@ -38,6 +38,16 @@ describe ProtectedBranch, models: true do
expect(protected_branch).to be_valid expect(protected_branch).to be_valid
end 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 end
context "while checking uniqueness of a user-based #{human_association_name}" do context "while checking uniqueness of a user-based #{human_association_name}" do
...@@ -65,6 +75,34 @@ describe ProtectedBranch, models: true do ...@@ -65,6 +75,34 @@ describe ProtectedBranch, models: true do
expect(protected_branch).to be_valid expect(protected_branch).to be_valid
end end
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
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