Commit fec98d0f authored by Douwe Maan's avatar Douwe Maan

Merge branch 'bvl-external-auth' into 'master'

External authorization service

Closes #4216

See merge request gitlab-org/gitlab-ee!4675
parents 3cc868a1 abfb082a
......@@ -134,10 +134,15 @@ class ApplicationController < ActionController::Base
Ability.allowed?(object, action, subject)
end
def access_denied!
def access_denied!(message = nil)
respond_to do |format|
format.json { head :not_found }
format.any { render "errors/access_denied", layout: "errors", status: 404 }
format.any { head :not_found }
format.html do
render "errors/access_denied",
layout: "errors",
status: 404,
locals: { message: message }
end
end
end
......
......@@ -57,7 +57,7 @@ module Boards
end
def issue
@issue ||= issues_finder.execute.find(params[:id])
@issue ||= issues_finder.find(params[:id])
end
def filter_params
......
module ControllerWithCrossProjectAccessCheck
extend ActiveSupport::Concern
included do
extend Gitlab::CrossProjectAccess::ClassMethods
before_action :cross_project_check
end
def cross_project_check
if Gitlab::CrossProjectAccess.find_check(self)&.should_run?(self)
authorize_cross_project_page!
end
end
def authorize_cross_project_page!
return if can?(current_user, :read_cross_project)
rejection_message = _(
"This page is unavailable because you are not allowed to read information "\
"across multiple projects."
)
access_denied!(rejection_message)
end
end
......@@ -3,16 +3,20 @@ module RoutableActions
def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, requested_full_path)
routable
else
route_not_found
handle_not_found_or_authorized(routable)
nil
end
end
# This is overridden in gitlab-ee.
def handle_not_found_or_authorized(_routable)
route_not_found
end
def routable_authorized?(routable, extra_authorization_proc)
action = :"read_#{routable.class.to_s.underscore}"
return false unless can?(current_user, action, routable)
......
class Dashboard::ApplicationController < ApplicationController
include ControllerWithCrossProjectAccessCheck
layout 'dashboard'
requires_cross_project_access
private
def projects
......
class Dashboard::GroupsController < Dashboard::ApplicationController
include GroupTree
skip_cross_project_access_check :index
def index
groups = GroupsFinder.new(current_user, all_available: false).execute
render_group_tree(groups)
......
......@@ -4,6 +4,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :set_non_archived_param
before_action :default_sorting
skip_cross_project_access_check :index, :starred
def index
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
......
class Dashboard::SnippetsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
def index
@snippets = SnippetsFinder.new(
current_user,
......
class Groups::ApplicationController < ApplicationController
include RoutableActions
prepend EE::Groups::ApplicationController
include ControllerWithCrossProjectAccessCheck
layout 'group'
skip_before_action :authenticate_user!
before_action :group
requires_cross_project_access
private
......
class Groups::AvatarsController < Groups::ApplicationController
before_action :authorize_admin_group!
skip_cross_project_access_check :destroy
def destroy
@group.remove_avatar!
@group.save
......
module Groups
class ChildrenController < Groups::ApplicationController
before_action :group
skip_cross_project_access_check :index
def index
parent = if params[:parent_id].present?
......
......@@ -9,6 +9,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access, :update, :override]
before_action :authorize_update_group_member!, only: [:update, :override]
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
:override
def index
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id]
......
module Groups
module Settings
class CiCdController < Groups::ApplicationController
skip_cross_project_access_check :show
before_action :authorize_admin_pipeline!
def show
......
......@@ -2,6 +2,8 @@ module Groups
class VariablesController < Groups::ApplicationController
before_action :authorize_admin_build!
skip_cross_project_access_check :show, :update
def show
respond_to do |format|
format.json do
......
......@@ -20,6 +20,12 @@ class GroupsController < Groups::ApplicationController
before_action :user_actions, only: [:show, :subgroups]
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
# When loading show as an atom feed, we render events that could leak cross
# project information
skip_cross_project_access_check :show, if: -> { request.format.html? }
layout :determine_layout
def index
......
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include Gitlab::GonHelper
include Gitlab::Allowable
include PageLayoutHelper
include OauthApplications
......@@ -8,6 +9,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit]
helper_method :can?
layout 'profile'
def index
......
class Projects::ApplicationController < ApplicationController
prepend EE::Projects::ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
......
......@@ -34,9 +34,9 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
def target
case params[:type]&.downcase
when 'issue'
IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
when 'mergerequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
when 'commit'
@project.commit(params[:type_id])
end
......
......@@ -133,7 +133,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def after_edit_path
from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid])
from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid])
if from_merge_request && @branch_name == @ref
diffs_project_merge_request_path(from_merge_request.target_project, from_merge_request) +
"##{hexdigest(@path)}"
......
......@@ -77,7 +77,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def branch_to
@target_project = selected_target_project
if params[:ref].present?
if @target_project && params[:ref].present?
@ref = params[:ref]
@commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
......@@ -87,7 +87,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def update_branches
@target_project = selected_target_project
@target_branches = @target_project.repository.branch_names
@target_branches = @target_project ? @target_project.repository.branch_names : []
render layout: false
end
......@@ -123,7 +123,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@project
elsif params[:target_project_id].present?
MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: @project)
.execute.find(params[:target_project_id])
.find_by(id: params[:target_project_id])
else
@project.forked_from_project
end
......
class SearchController < ApplicationController
skip_before_action :authenticate_user!
include ControllerWithCrossProjectAccessCheck
include SearchHelper
include RendersCommits
skip_before_action :authenticate_user!
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
search_term_present && !params[:project_id].present?
end
layout 'search'
def show
......
class UsersController < ApplicationController
include RoutableActions
include RendersMemberAccess
include ControllerWithCrossProjectAccessCheck
requires_cross_project_access show: false,
groups: false,
projects: false,
contributed: false,
snippets: true,
calendar: false,
calendar_activities: true
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
......@@ -103,12 +112,7 @@ class UsersController < ApplicationController
end
def load_events
# Get user activity feed for projects common for both users
@events = user.recent_events
.merge(projects_for_current_user)
.references(:project)
.with_associations
.limit_recent(20, params[:offset])
@events = UserRecentEventsFinder.new(current_user, user, params).execute
Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
......@@ -141,10 +145,6 @@ class UsersController < ApplicationController
).execute.page(params[:page])
end
def projects_for_current_user
ProjectsFinder.new(current_user: current_user).execute
end
def build_canonical_path(user)
url_for(params.merge(username: user.to_param))
end
......
module FinderMethods
def find_by!(*args)
raise_not_found_unless_authorized execute.find_by!(*args)
end
def find_by(*args)
if_authorized execute.find_by(*args)
end
def find(*args)
raise_not_found_unless_authorized model.find(*args)
end
private
def raise_not_found_unless_authorized(result)
result = if_authorized(result)
raise ActiveRecord::RecordNotFound.new("Couldn't find #{model}") unless result
result
end
def if_authorized(result)
# Return the result if the finder does not perform authorization checks.
# this is currently the case in the `MilestoneFinder`
return result unless respond_to?(:current_user)
if can_read_object?(result)
result
else
nil
end
end
def can_read_object?(object)
# When there's no policy, we'll allow the read, this is for example the case
# for Todos
return true unless DeclarativePolicy.has_policy?(object)
model_name = object&.model_name || model.model_name
Ability.allowed?(current_user, :"read_#{model_name.singular}", object)
end
# This fetches the model from the `ActiveRecord::Relation` but does not
# actually execute the query.
def model
execute.model
end
end
# Module to prepend into finders to specify wether or not the finder requires
# cross project access
#
# This module depends on the finder implementing the following methods:
#
# - `#execute` should return an `ActiveRecord::Relation`
# - `#current_user` the user that requires access (or nil)
module FinderWithCrossProjectAccess
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
prepended do
extend Gitlab::CrossProjectAccess::ClassMethods
end
override :execute
def execute(*args)
check = Gitlab::CrossProjectAccess.find_check(self)
original = super
return original unless check
return original if should_skip_cross_project_check || can_read_cross_project?
if check.should_run?(self)
original.model.none
else
original
end
end
# We can skip the cross project check for finding indivitual records.
# this would be handled by the `can?(:read_*, result)` call in `FinderMethods`
# itself.
override :find_by!
def find_by!(*args)
skip_cross_project_check { super }
end
override :find_by
def find_by(*args)
skip_cross_project_check { super }
end
override :find
def find(*args)
skip_cross_project_check { super }
end
private
attr_accessor :should_skip_cross_project_check
def skip_cross_project_check
self.should_skip_cross_project_check = true
yield
ensure
# The find could raise an `ActiveRecord::RecordNotFound`, after which we
# still want to re-enable the check.
self.should_skip_cross_project_check = false
end
def can_read_cross_project?
Ability.allowed?(current_user, :read_cross_project)
end
def can_read_project?(project)
Ability.allowed?(current_user, :read_project, project)
end
end
class EventsFinder
prepend FinderMethods
prepend FinderWithCrossProjectAccess
attr_reader :source, :params, :current_user
requires_cross_project_access unless: -> { source.is_a?(Project) }
# Used to filter Events
#
# Arguments:
......
......@@ -21,8 +21,12 @@
# my_reaction_emoji: string
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
include CreatedAtFilter
requires_cross_project_access unless: -> { project? }
NONE = '0'.freeze
attr_accessor :current_user, :params
......@@ -87,14 +91,6 @@ class IssuableFinder
by_my_reaction_emoji(items)
end
def find(*params)
execute.find(*params)
end
def find_by(*params)
execute.find_by(*params)
end
def row_count
Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
end
......@@ -124,10 +120,6 @@ class IssuableFinder
counts.with_indifferent_access
end
def find_by!(*params)
execute.find_by!(*params)
end
def group
return @group if defined?(@group)
......
class LabelsFinder < UnionFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
include Gitlab::Utils::StrongMemoize
requires_cross_project_access unless: -> { project? }
def initialize(current_user, params = {})
@current_user = current_user
@params = params
......
class MergeRequestTargetProjectFinder
include FinderMethods
attr_reader :current_user, :source_project
def initialize(current_user: nil, source_project:)
......
......@@ -8,6 +8,8 @@
# state - filters by state.
class MilestonesFinder
include FinderMethods
attr_reader :params, :project_ids, :group_ids
def initialize(params = {})
......
......@@ -13,7 +13,9 @@
# params are optional
class SnippetsFinder < UnionFinder
include Gitlab::Allowable
attr_accessor :current_user, :params, :project
include FinderMethods
attr_accessor :current_user, :project, :params
def initialize(current_user, params = {})
@current_user = current_user
......@@ -52,10 +54,14 @@ class SnippetsFinder < UnionFinder
end
def authorized_snippets
Snippet.where(feature_available_projects.or(not_project_related)).public_or_visible_to_user(current_user)
Snippet.where(feature_available_projects.or(not_project_related))
.public_or_visible_to_user(current_user)
end
def feature_available_projects
# Don't return any project related snippets if the user cannot read cross project
return table[:id].eq(nil) unless Ability.allowed?(current_user, :read_cross_project)
projects = Project.public_or_visible_to_user(current_user, use_where_in: false) do |part|
part.with_feature_available_for_user(:snippets, current_user)
end.select(:id)
......
......@@ -13,6 +13,11 @@
#
class TodosFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
requires_cross_project_access unless: -> { project? }
NONE = '0'.freeze
attr_accessor :current_user, :params
......
# Get user activity feed for projects common for a user and a logged in user
#
# - current_user: The user viewing the events
# - user: The user for which to load the events
# - params:
# - offset: The page of events to return
class UserRecentEventsFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
requires_cross_project_access
attr_reader :current_user, :target_user, :params
def initialize(current_user, target_user, params = {})
@current_user = current_user
@target_user = target_user
@params = params
end
def execute
target_user
.recent_events
.merge(projects_for_current_user)
.references(:project)
.with_associations
.limit_recent(20, params[:offset])
end
def projects_for_current_user
ProjectsFinder.new(current_user: current_user).execute
end
end
......@@ -6,4 +6,28 @@ module DashboardHelper
def assigned_mrs_dashboard_path
merge_requests_dashboard_path(assignee_id: current_user.id)
end
def dashboard_nav_links
@dashboard_nav_links ||= get_dashboard_nav_links
end
def dashboard_nav_link?(link)
dashboard_nav_links.include?(link)
end
def any_dashboard_nav_link?(links)
links.any? { |link| dashboard_nav_link?(link) }
end
private
def get_dashboard_nav_links
links = [:projects, :groups, :snippets]
if can?(current_user, :read_cross_project)
links += [:activity, :milestones]
end
links
end
end
......@@ -25,8 +25,24 @@ module ExploreHelper
controller.class.name.split("::").first == "Explore"
end
def explore_nav_links
@explore_nav_links ||= get_explore_nav_links
end
def explore_nav_link?(link)
explore_nav_links.include?(link)
end
def any_explore_nav_link?(links)
links.any? { |link| explore_nav_link?(link) }
end
private
def get_explore_nav_links
[:projects, :groups, :snippets]
end
def request_path_with_options(options = {})
request.path + "?#{options.to_param}"
end
......
......@@ -5,6 +5,14 @@ module GroupsHelper
%w[groups#projects groups#edit ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end
def group_sidebar_links
@group_sidebar_links ||= get_group_sidebar_links
end
def group_sidebar_link?(link)
group_sidebar_links.include?(link)
end
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
......@@ -115,6 +123,20 @@ module GroupsHelper
private
def get_group_sidebar_links
links = [:overview, :group_members]
if can?(current_user, :read_cross_project)
links += [:activity, :issues, :labels, :milestones, :merge_requests]
end
if can?(current_user, :admin_group, @group)
links << :settings
end
links
end
def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do
output =
......
......@@ -49,27 +49,6 @@ module IssuesHelper
end
end
def milestone_options(object)
milestones = object.project.milestones.active.reorder(due_date: :asc, title: :asc).to_a
milestones.unshift(object.milestone) if object.milestone.present? && object.milestone.closed?
milestones.unshift(Milestone::None)
options_from_collection_for_select(milestones, 'id', 'title', object.milestone_id)
end
def project_options(issuable, current_user, ability: :read_project)
projects = current_user.authorized_projects.order_id_desc
projects = projects.select do |project|
current_user.can?(ability, project)
end
no_project = OpenStruct.new(id: 0, name_with_namespace: 'No project')
projects.unshift(no_project)
projects.delete(issuable.project)
options_from_collection_for_select(projects, :id, :name_with_namespace)
end
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'
......
module NavHelper
def header_links
@header_links ||= get_header_links
end
def header_link?(link)
header_links.include?(link)
end
def page_with_sidebar_class
class_name = page_gutter_class
class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar
......@@ -39,4 +47,28 @@ module NavHelper
class_names
end
private
def get_header_links
links = if current_user
[:user_dropdown]
else
[:sign_in]
end
if can?(current_user, :read_cross_project)
links += [:issues, :merge_requests, :todos] if current_user.present?
end
if @project&.persisted? || can?(current_user, :read_cross_project)
links << :search
end
if session[:impersonator_id]
links << :admin_impersonation
end
links
end
end
......@@ -210,6 +210,7 @@ module ProjectsHelper
controller.controller_name,
controller.action_name,
Gitlab::CurrentSettings.cache_key,
"cross-project:#{can?(current_user, :read_cross_project)}",
'v2.5'
]
......@@ -544,4 +545,8 @@ module ProjectsHelper
project_find_file_path(@project, ref)
end
def can_show_last_commit_in_list?(project)
can?(current_user, :read_cross_project) && project.commit
end
end
......@@ -14,4 +14,18 @@ module UsersHelper
content_tag(:strong) { user.unconfirmed_email } + h('.') +
content_tag(:p) { confirmation_link }
end
def profile_tabs
@profile_tabs ||= get_profile_tabs
end
def profile_tab?(tab)
profile_tabs.include?(tab)
end
private
def get_profile_tabs
[:activity, :groups, :contributed, :projects, :snippets]
end
end
......@@ -22,12 +22,30 @@ class Ability
#
# issues - The issues to reduce down to those readable by the user.
# user - The User for which to check the issues
def issues_readable_by_user(issues, user = nil)
# filters - A hash of abilities and filters to apply if the user lacks this
# ability
def issues_readable_by_user(issues, user = nil, filters: {})
issues = apply_filters_if_needed(issues, user, filters)
DeclarativePolicy.user_scope do
issues.select { |issue| issue.visible_to_user?(user) }
end
end
# Returns an Array of MergeRequests that can be read by the given user.
#
# merge_requests - MRs out of which to collect mr's readable by the user.
# user - The User for which to check the merge_requests
# filters - A hash of abilities and filters to apply if the user lacks this
# ability
def merge_requests_readable_by_user(merge_requests, user = nil, filters: {})
merge_requests = apply_filters_if_needed(merge_requests, user, filters)
DeclarativePolicy.user_scope do
merge_requests.select { |mr| allowed?(user, :read_merge_request, mr) }
end
end
def can_edit_note?(user, note)
allowed?(user, :edit_note, note)
end
......@@ -53,5 +71,15 @@ class Ability
cache = RequestStore.active? ? RequestStore : {}
DeclarativePolicy.policy_for(user, subject, cache: cache)
end
private
def apply_filters_if_needed(elements, user, filters)
filters.each do |ability, filter|
elements = filter.call(elements) unless allowed?(user, ability)
end
elements
end
end
end
......@@ -35,6 +35,7 @@ module ProtectedRefAccess
def check_access(user)
return true if user.admin?
user.can?(:push_code, project) &&
project.team.max_member_access(user.id) >= access_level
end
end
......@@ -177,7 +177,18 @@ class Issue < ActiveRecord::Base
object.all_references(current_user, extractor: ext)
end
ext.merge_requests.sort_by(&:iid)
merge_requests = ext.merge_requests.sort_by(&:iid)
cross_project_filter = -> (merge_requests) do
merge_requests.select { |mr| mr.target_project == project }
end
Ability.merge_requests_readable_by_user(
merge_requests, current_user,
filters: {
read_cross_project: cross_project_filter
}
)
end
# All branches containing the current issue's ID, except for
......@@ -202,7 +213,11 @@ class Issue < ActiveRecord::Base
.preload(preload)
.reorder('issue_link_id')
Ability.issues_readable_by_user(related_issues, current_user)
cross_project_filter = -> (issues) { issues.where(project: project) }
Ability.issues_readable_by_user(
related_issues, current_user,
filters: { read_cross_project: cross_project_filter }
)
end
# Returns boolean if a related branch exists for the current issue
......
......@@ -85,6 +85,7 @@ class NotificationRecipient
return false unless user.can?(:receive_notifications)
return true if @skip_read_ability
return false if @target && !user.can?(:read_cross_project)
return false if @project && !user.can?(:read_project, @project)
return true unless read_ability
......
......@@ -1048,6 +1048,9 @@ class Project < ActiveRecord::Base
end
def user_can_push_to_empty_repo?(user)
return false unless empty_repo?
return false unless Ability.allowed?(user, :push_code, self)
!ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end
......
require_dependency 'declarative_policy'
class BasePolicy < DeclarativePolicy::Base
prepend EE::BasePolicy
desc "User is an instance admin"
with_options scope: :user, score: 0
condition(:admin) { @user&.admin? }
......@@ -16,6 +18,9 @@ class BasePolicy < DeclarativePolicy::Base
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
# This is prevented in some cases in `gitlab-ee`
rule { default }.enable :read_cross_project
# EE Extensions
with_scope :user
condition(:auditor, score: 0) { @user&.auditor? }
......
......@@ -3,6 +3,19 @@ class IssuablePolicy < BasePolicy
condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be allowed
# to read the actual issue after a more expensive `:read_issue` check.
#
# `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee.
condition(:visible_to_user, score: 4) do
Project.where(id: @subject.project)
.public_or_visible_to_user(@user)
.with_feature_available_for_user(@subject, @user)
.any?
end
condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
desc "User is the assignee or author"
......
......@@ -13,7 +13,10 @@ class IssuePolicy < IssuablePolicy
rule { confidential & ~can_read_confidential }.policy do
prevent :read_issue
prevent :read_issue_iid
prevent :update_issue
prevent :admin_issue
end
rule { can?(:read_issue) | visible_to_user }.enable :read_issue_iid
end
class MergeRequestPolicy < IssuablePolicy
prepend EE::MergeRequestPolicy
# pass
rule { can?(:read_merge_request) | visible_to_user }.enable :read_merge_request_iid
end
......@@ -82,8 +82,9 @@ class ProjectPolicy < BasePolicy
rule { reporter }.enable :reporter_access
rule { developer }.enable :developer_access
rule { master }.enable :master_access
rule { owner | admin }.enable :owner_access
rule { owner | admin }.policy do
rule { can?(:owner_access) }.policy do
enable :guest_access
enable :reporter_access
enable :developer_access
......@@ -100,12 +101,6 @@ class ProjectPolicy < BasePolicy
enable :remove_pages
end
rule { owner | reporter }.policy do
enable :build_read_project
enable :build_download_code
enable :build_read_container_image
end
rule { can?(:guest_access) }.policy do
enable :read_project
enable :read_board
......@@ -124,6 +119,11 @@ class ProjectPolicy < BasePolicy
enable :read_cycle_analytics
end
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separatly.
rule { guest & can?(:download_code) }.enable :build_download_code
rule { guest & can?(:read_container_image) }.enable :build_read_container_image
rule { can?(:reporter_access) }.policy do
enable :download_code
enable :download_wiki_code
......@@ -143,12 +143,19 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
# where we enable or prevent it based on other coditions.
rule { (~anonymous & public_project) | internal_access }.policy do
enable :public_user_access
end
rule { can?(:public_user_access) }.policy do
enable :public_access
enable :guest_access
enable :fork_project
enable :build_download_code
enable :build_read_container_image
enable :request_access
end
......@@ -199,14 +206,6 @@ class ProjectPolicy < BasePolicy
enable :create_cluster
end
rule { can?(:public_user_access) }.policy do
enable :public_access
enable :fork_project
enable :build_download_code
enable :build_read_container_image
end
rule { archived }.policy do
prevent :create_merge_request
prevent :push_code
......
......@@ -11,9 +11,7 @@ class GroupChildEntity < Grape::Entity
end
expose :can_edit do |instance|
return false unless request.respond_to?(:current_user)
can?(request.current_user, "admin_#{type}", instance)
can_edit?
end
expose :edit_path do |instance|
......@@ -83,4 +81,17 @@ class GroupChildEntity < Grape::Entity
def markdown_description
markdown_field(object, :description)
end
def can_edit?
return false unless request.respond_to?(:current_user)
if project?
# Avoid checking rights for each project, as it might be expensive if the
# user cannot read cross project.
can?(request.current_user, :read_cross_project) &&
can?(request.current_user, :admin_project, object)
else
can?(request.current_user, :admin_group, object)
end
end
end
......@@ -29,7 +29,7 @@ module Boards
{ project_ids: [parent.id], group_ids: [parent.group&.id] }
end
milestone = MilestonesFinder.new(finder_params).execute.find_by_id(milestone_id)
milestone = MilestonesFinder.new(finder_params).find_by(id: milestone_id)
params[:milestone_id] = milestone&.id
end
......
......@@ -249,7 +249,7 @@ class IssuableBaseService < BaseService
when 'add'
todo_service.mark_todo(issuable, current_user)
when 'done'
todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
todo = TodosFinder.new(current_user).find_by(target: issuable)
todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
end
end
......
......@@ -924,5 +924,8 @@
.col-sm-10
= f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
- if License.feature_available?(:external_authorization_service)
= render partial: 'external_authorization_service_form', locals: { f: f }
.form-actions
= f.submit 'Save', class: 'btn btn-save'
- message = local_assigns.fetch(:message)
- content_for(:title, 'Access Denied')
%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') }
%h1
......@@ -5,5 +7,9 @@
.container
%h3 Access Denied
%hr
- if message
%p
= message
- else
%p You are not allowed to access this page.
%p Read more about project permissions #{link_to "here", help_page_path("user/permissions"), class: "vlink"}
......@@ -8,6 +8,7 @@
= render "layouts/header/ee_license_banner"
= render "layouts/broadcast"
= render 'layouts/header/ee/geo_secondary_banner'
= render "layouts/nav/ee/classification_level_banner"
= yield :flash_message
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
......
......@@ -20,29 +20,34 @@
%ul.nav.navbar-nav
- if current_user
= render 'layouts/header/new_dropdown'
- if header_link?(:search)
%li.hidden-sm.hidden-xs
= render 'layouts/search' unless current_controller?(:search)
%li.visible-sm-inline-block.visible-xs-inline-block
= link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('search', size: 16)
- if current_user
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('issues', size: 16)
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('git-merge', size: 16)
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
= number_with_delimiter(merge_requests_count)
- if header_link?(:todos)
= nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('todo-done', size: 16)
%span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
- if header_link?(:user_dropdown)
%li.header-user.dropdown
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
......@@ -64,11 +69,11 @@
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, class: "sign-out-link"
- if session[:impersonator_id]
- if header_link?(:admin_impersonation)
%li.impersonation
= link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret')
- else
- if header_link?(:sign_in)
%li
%div
= link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
......
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
%a{ href: "#", data: { toggle: "dropdown" } }
Projects
......@@ -6,45 +7,55 @@
.dropdown-menu.projects-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do
Groups
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
Activity
- if dashboard_nav_link?(:milestones)
= nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
Milestones
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
Snippets
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
%li.header-more.dropdown.hidden-lg
%a{ href: "#", data: { toggle: "dropdown" } }
More
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu
%ul
- if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
Groups
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, title: 'Activity' do
Activity
- if dashboard_nav_link?(:milestones)
= nav_link(controller: 'dashboard/milestones') do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
Milestones
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets') do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
Snippets
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
......
%ul.list-unstyled.navbar-sub-nav
- if explore_nav_link?(:projects)
= nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
= link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
- if explore_nav_link?(:groups)
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
Groups
- if explore_nav_link?(:snippets)
= nav_link(controller: :snippets) do
= link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
Snippets
......
......@@ -14,6 +14,7 @@
.sidebar-context-title
= @group.name
%ul.sidebar-top-level-items
- if group_sidebar_link?(:overview)
= nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups', 'analytics#show'], html_options: { class: 'home' }) do
= link_to group_path(@group) do
.nav-icon-container
......@@ -32,20 +33,21 @@
%span
Details
- if group_sidebar_link?(:activity)
= nav_link(path: 'groups#activity') do
= link_to activity_group_path(@group), title: 'Activity' do
%span
Activity
- if @group.feature_available?(:contribution_analytics) || show_promotions?
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
= link_to group_analytics_path(@group), title: 'Contribution Analytics', data: {placement: 'right'} do
%span
Contribution Analytics
= render "layouts/nav/ee/epic_link", group: @group
- if group_sidebar_link?(:issues)
= nav_link(path: issues_sub_menu_items) do
= link_to issues_group_path(@group) do
.nav-icon-container
......@@ -66,22 +68,25 @@
%span
List
- if @group.feature_available?(:group_issue_boards)
- if group_sidebar_link?(:boards)
= nav_link(path: ['boards#index', 'boards#show']) do
= link_to group_boards_path(@group), title: 'Boards' do
%span
Boards
- if group_sidebar_link?(:labels)
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do
%span
Labels
- if group_sidebar_link?(:milestones)
= nav_link(path: 'milestones#index') do
= link_to group_milestones_path(@group), title: 'Milestones' do
%span
Milestones
- if group_sidebar_link?(:merge_requests)
= nav_link(path: 'groups#merge_requests') do
= link_to merge_requests_group_path(@group) do
.nav-icon-container
......@@ -95,6 +100,8 @@
%strong.fly-out-top-item-name
#{ _('Merge Requests') }
%span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count)
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
= link_to group_group_members_path(@group) do
.nav-icon-container
......@@ -106,7 +113,8 @@
= link_to group_group_members_path(@group) do
%strong.fly-out-top-item-name
#{ _('Members') }
- if current_user && can?(current_user, :admin_group, @group)
- if group_sidebar_link?(:settings)
= nav_link(path: group_nav_link_paths) do
= link_to edit_group_path(@group) do
.nav-icon-container
......
......@@ -37,6 +37,8 @@
%span.light (optional)
= f.text_area :description, class: "form-control", rows: 3, maxlength: 250
= render 'projects/ee/classification_policy_settings', f: f
- unless @project.empty_repo?
.form-group
= f.label :default_branch, "Default Branch", class: 'label-light'
......
......@@ -6,7 +6,7 @@
- user = local_assigns[:user]
- access = user&.max_member_access_for_project(project.id) unless user.nil?
- css_class = '' unless local_assigns[:css_class]
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
......@@ -47,7 +47,7 @@
.prepend-top-0
- if project.archived
%span.prepend-left-10.label.label-warning archived
- if project.pipeline_status.has_status?
- if can?(current_user, :read_cross_project) && project.pipeline_status.has_status?
%span.prepend-left-10
= render_project_pipeline_status(project.pipeline_status)
- if forks
......
......@@ -82,24 +82,30 @@
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.nav-links.user-profile-nav.scrolling-tabs
- if profile_tab?(:activity)
%li.js-activity-tab
= link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
Activity
- if profile_tab?(:groups)
%li.js-groups-tab
= link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
Groups
- if profile_tab?(:contributed)
%li.js-contributed-tab
= link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
Contributed projects
- if profile_tab?(:projects)
%li.js-projects-tab
= link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
Personal projects
- if profile_tab?(:snippets)
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
Snippets
%div{ class: container_class }
.tab-content
- if profile_tab?(:activity)
#activity.tab-pane
.row-content-block.calender-block.white.second-block.hidden-xs
.user-calendar{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
......@@ -107,20 +113,25 @@
%i.fa.fa-spinner.fa-spin
.user-calendar-activities
- if can?(current_user, :read_cross_project)
%h4.prepend-top-20
Most Recent Activity
.content_list{ data: { href: user_path } }
= spinner
- if profile_tab?(:groups)
#groups.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:contributed)
#contributed.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:projects)
#projects.tab-pane
-# This tab is always loaded via AJAX
- if profile_tab?(:snippets)
#snippets.tab-pane
-# This tab is always loaded via AJAX
......
---
title: Authorize project access with an external service
merge_request: 4675
author:
type: added
......@@ -180,6 +180,9 @@ ActiveRecord::Schema.define(version: 20180215143644) do
t.boolean "mirror_available", default: true, null: false
t.string "auto_devops_domain"
t.integer "default_project_creation", default: 2, null: false
t.boolean "external_authorization_service_enabled", default: false, null: false
t.string "external_authorization_service_url"
t.string "external_authorization_service_default_label"
end
create_table "approvals", force: :cascade do |t|
......@@ -1914,6 +1917,7 @@ ActiveRecord::Schema.define(version: 20180215143644) do
t.boolean "pull_mirror_available_overridden"
t.integer "jobs_cache_index"
t.boolean "mirror_overwrites_diverged_branches"
t.string "external_authorization_classification_label"
end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
......
......@@ -76,3 +76,6 @@
justify-content: center;
}
}
.classification-label {
background-color: $common-red;
}
......@@ -12,6 +12,10 @@ module EE
attrs << :default_project_creation
end
if License.feature_available?(:external_authorization_service)
attrs += EE::ApplicationSettingsHelper.external_authorization_service_attributes
end
attrs
end
end
......
module EE
module Boards
module IssuesController
extend ActiveSupport::Concern
include ControllerWithCrossProjectAccessCheck
prepended do
requires_cross_project_access if: -> { board.group_board? }
end
def issues_finder
return super unless board.group_board?
......
module EE
module Groups
module ApplicationController
extend ActiveSupport::Concern
def check_group_feature_available!(feature)
render_404 unless group.feature_available?(feature)
end
......
module EE
module Groups
module GroupMembersController
extend ActiveSupport::Concern
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def override
@group_member = @group.group_members.find(params[:id])
......
module EE
module GroupsController
extend ActiveSupport::Concern
def group_params_attributes
super + group_params_ee
end
......
module EE
module Projects
module ApplicationController
extend ::Gitlab::Utils::Override
override :handle_not_found_or_authorized
def handle_not_found_or_authorized(project)
return super unless project
label = project.external_authorization_classification_label
rejection_reason = nil
unless EE::Gitlab::ExternalAuthorization.access_allowed?(current_user, label)
rejection_reason = EE::Gitlab::ExternalAuthorization.rejection_reason(current_user, label)
rejection_reason ||= _('External authorization denied access to this project')
end
if rejection_reason
access_denied!(rejection_reason)
else
super
end
end
end
end
end
......@@ -20,6 +20,7 @@ module EE
mirror
mirror_trigger_builds
mirror_user_id
external_authorization_classification_label
]
end
end
......
......@@ -34,5 +34,17 @@ module EE
:mirror_capacity_threshold
]
end
def self.external_authorization_service_attributes
[
:external_authorization_service_enabled,
:external_authorization_service_url,
:external_authorization_service_default_label
]
end
def self.possible_licensed_attributes
repository_mirror_attributes + external_authorization_service_attributes
end
end
end
......@@ -10,5 +10,27 @@ module EE
super
end
end
private
def get_group_sidebar_links
links = super
if can?(current_user, :read_cross_project)
if @group.feature_available?(:contribution_analytics) || show_promotions?
links << :contribution_analytics
end
if @group.feature_available?(:group_issue_boards)
links << :boards
end
if @group.feature_available?(:epics)
links << :epics
end
end
links
end
end
end
......@@ -5,5 +5,15 @@ module EE
can?(current_user, :"change_#{rule}", @project)
end
def external_classification_label_help_message
default_label = ::Gitlab::CurrentSettings.current_application_settings
.external_authorization_service_default_label
s_(
"ExternalAuthorizationService|When no classification label is set the "\
"default label `%{default_label}` will be used."
) % { default_label: default_label }
end
end
end
......@@ -185,6 +185,12 @@ module Elastic
# documents gated by that project feature - e.g., "issues". The feature's
# visibility level must be taken into account.
def project_ids_query(user, project_ids, public_and_internal_projects, feature = nil)
# When reading cross project is not allowed, only allow searching a
# a single project, so the `:read_*` ability is only checked once.
unless Ability.allowed?(user, :read_cross_project)
project_ids = [] if project_ids.is_a?(Array) && project_ids.size > 1
end
# At least one condition must be present, so pick no projects for
# anonymous users.
# Pick private, internal and public projects the user is a member of.
......
......@@ -66,7 +66,7 @@ module Elastic
bool: {
should: [
{ term: { author_id: user.id } },
{ terms: { project_id: user.authorized_projects.pluck(:id) } },
{ terms: { project_id: authorized_project_ids_for_user(user) } },
{
bool: {
filter: { terms: { visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL] } },
......@@ -88,6 +88,14 @@ module Elastic
query_hash[:query][:bool][:filter] = filter
query_hash
end
def self.authorized_project_ids_for_user(user)
if Ability.allowed?(user, :read_cross_project)
user.authorized_projects.pluck(:id)
else
[]
end
end
end
end
end
......@@ -39,6 +39,15 @@ module EE
validates :elasticsearch_aws_region,
presence: { message: "can't be blank when using aws hosted elasticsearch" },
if: ->(setting) { setting.elasticsearch_indexing? && setting.elasticsearch_aws? }
validates :external_authorization_service_url,
:external_authorization_service_default_label,
presence: true,
if: :external_authorization_service_enabled?
validates :external_authorization_service_url,
url: true,
if: :external_authorization_service_enabled?
end
module ClassMethods
......@@ -103,6 +112,12 @@ module EE
}
end
def external_authorization_service_enabled
License.feature_available?(:external_authorization_service) && super
end
alias_method :external_authorization_service_enabled?,
:external_authorization_service_enabled
private
def mirror_max_delay_in_minutes
......
module EE
module Issue
extend ::Gitlab::Utils::Override
# override
def check_for_spam?
author.support_bot? || super
......@@ -34,6 +36,23 @@ module EE
super if supports_weight?
end
# The functionality here is duplicated from the `IssuePolicy` and the
# `EE::IssuePolicy` for better performace
#
# Make sure to keep this in sync with the policies.
override :readable_by?
def readable_by?(user)
return super if user.full_private_access?
super && ::EE::Gitlab::ExternalAuthorization
.access_allowed?(user, project.external_authorization_classification_label)
end
override :publicly_visible?
def publicly_visible?
super && !::EE::Gitlab::ExternalAuthorization.enabled?
end
def supports_weight?
project&.feature_available?(:issue_weights)
end
......
......@@ -457,6 +457,13 @@ module EE
::Gitlab::CurrentSettings.mirror_available
end
def external_authorization_classification_label
return nil unless feature_available?(:external_authorization_service)
super || ::Gitlab::CurrentSettings.current_application_settings
.external_authorization_service_default_label
end
private
def set_override_pull_mirror_available
......
......@@ -53,6 +53,7 @@ class License < ActiveRecord::Base
reject_unsigned_commits
commit_committer_check
project_creation_level
external_authorization_service
].freeze
EEU_FEATURES = EEP_FEATURES + %i[
......@@ -133,6 +134,7 @@ class License < ActiveRecord::Base
multiple_ldap_servers
object_storage
repository_size_limit
external_authorization_service
].freeze
validate :valid_license
......
module EE
module BasePolicy
extend ActiveSupport::Concern
prepended do
condition(:external_authorization_enabled, scope: :global, score: 0) do
::EE::Gitlab::ExternalAuthorization.enabled?
end
rule { external_authorization_enabled & ~admin & ~auditor }.policy do
prevent :read_cross_project
end
end
end
end
......@@ -15,6 +15,14 @@ module EE
with_scope :subject
condition(:deploy_board_disabled) { !@subject.feature_available?(:deploy_board) }
with_scope :subject
condition(:classification_label_authorized, score: 32) do
EE::Gitlab::ExternalAuthorization.access_allowed?(
@user,
@subject.external_authorization_classification_label
)
end
with_scope :global
condition(:is_development) { Rails.env.development? }
......@@ -95,6 +103,24 @@ module EE
rule { admin | (reject_unsigned_commits_disabled_globally & can?(:master_access)) }.enable :change_reject_unsigned_commits
rule { admin | (commit_committer_check_disabled_globally & can?(:master_access)) }.enable :change_commit_committer_check
rule { owner | reporter }.enable :build_read_project
rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do
# Preventing access here still allows the projects to be listed. Listing
# projects doesn't check the `:read_project` ability. But instead counts
# on the `project_authorizations` table.
#
# All other actions should explicitly check read project, which would
# trigger the `classification_label_authorized` condition.
prevent :guest_access
prevent :public_access
prevent :public_user_access
prevent :reporter_access
prevent :developer_access
prevent :master_access
prevent :owner_access
end
end
end
end
class EpicPolicy < BasePolicy
delegate { @subject.group }
rule { can?(:read_epic) }.enable :read_epic_iid
end
- if License.feature_available?(:external_authorization_service)
%fieldset
%legend
= _('External Classification Policy Authorization')
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :external_authorization_service_enabled do
= f.check_box :external_authorization_service_enabled
Enable classification control using an external service
%span.help-block
If enabled, access to projects will be validated on an external service
using their classification label.
= link_to icon('question-circle'), help_page_path('#')
.form-group
= f.label :external_authorization_service_url, _('Service URL'), class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :external_authorization_service_url, class: 'form-control'
.form-group
= f.label :external_authorization_service_default_label, _('Default classification label'), class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :external_authorization_service_default_label, class: 'form-control'
- if EE::Gitlab::ExternalAuthorization.enabled? && @project
= content_for :header_content do
%span.label.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
= sprite_icon('lock-open', size: 8, css_class: 'inline')
= @project.external_authorization_classification_label
- return unless group.feature_available?(:epics)
- return unless group_sidebar_link?(:epics)
- epics = EpicsFinder.new(current_user, group_id: @group.id).execute
- epics_items = ['epics#show', 'epics#index', 'roadmap#show']
......
- if ::EE::Gitlab::ExternalAuthorization.enabled?
.form-group
= f.label :external_authorization_classification_label, class: 'label-light' do
= s_('ExternalAuthorizationService|Classification Label')
%span.light (optional)
= f.text_field :external_authorization_classification_label, class: "form-control"
%span.help-block
= external_classification_label_help_message
class AddExternalClassificationAuthorizationSettingsToApplictionSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :application_settings,
:external_authorization_service_enabled,
:boolean,
default: false
add_column :application_settings,
:external_authorization_service_url,
:string
add_column :application_settings,
:external_authorization_service_default_label,
:string
end
def down
remove_column :application_settings,
:external_authorization_service_default_label
remove_column :application_settings,
:external_authorization_service_url
remove_column :application_settings,
:external_authorization_service_enabled
end
end
class AddExternalAuthorizationServiceClassificationLabelToProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :projects,
:external_authorization_classification_label,
:string
end
end
module EE
module Gitlab
module ExternalAuthorization
RequestFailed = Class.new(StandardError)
def self.access_allowed?(user, label)
return true unless enabled?
return false unless user
access_for_user_to_label(user, label).has_access?
end
def self.rejection_reason(user, label)
return nil unless enabled?
return nil unless user
access_for_user_to_label(user, label).reason
end
def self.access_for_user_to_label(user, label)
if RequestStore.active?
RequestStore.fetch("external_authorisation:user-#{user.id}:label-#{label}") do
EE::Gitlab::ExternalAuthorization::Access.new(user, label).load!
end
else
EE::Gitlab::ExternalAuthorization::Access.new(user, label).load!
end
end
def self.enabled?
::Gitlab::CurrentSettings
.current_application_settings
.external_authorization_service_enabled?
end
def self.service_url
::Gitlab::CurrentSettings
.current_application_settings
.external_authorization_service_url
end
end
end
end
module EE
module Gitlab
module ExternalAuthorization
class Access
attr_reader :access, :reason, :loaded_at
def initialize(user, label)
@user, @label = user, label
end
def loaded?
loaded_at && (loaded_at > Cache::VALIDITY_TIME.ago)
end
def has_access?
@access
end
def load!
load_from_cache
load_from_service unless loaded?
self
end
private
def load_from_cache
@access, @reason, @loaded_at = cache.load
end
def load_from_service
response = Client.build(@user, @label).request_access
@access = response.successful?
@reason = response.reason
@loaded_at = Time.now
cache.store(@access, @reason, @loaded_at) if response.valid?
rescue EE::Gitlab::ExternalAuthorization::RequestFailed => e
@access = false
@reason = e.message
@loaded_at = Time.now
end
def cache
@cache ||= Cache.new(@user, @label)
end
end
end
end
end
module EE
module Gitlab
module ExternalAuthorization
class Cache
VALIDITY_TIME = 6.hours
def initialize(user, label)
@user, @label = user, label
end
def load
@access, @reason, @refreshed_at = ::Gitlab::Redis::Cache.with do |redis|
redis.hmget(cache_key, :access, :reason, :refreshed_at)
end
[access, reason, refreshed_at]
end
def store(new_access, new_reason, new_refreshed_at)
::Gitlab::Redis::Cache.with do |redis|
redis.pipelined do
redis.mapped_hmset(
cache_key,
{
access: new_access.to_s,
reason: new_reason.to_s,
refreshed_at: new_refreshed_at.to_s
}
)
redis.expire(cache_key, VALIDITY_TIME)
end
end
end
private
def access
::Gitlab::Utils.to_boolean(@access)
end
def reason
# `nil` if the cached value was an empty string
return nil unless @reason.present?
@reason
end
def refreshed_at
# Don't try to parse a time if there was no cache
return nil unless @refreshed_at.present?
Time.parse(@refreshed_at)
end
def cache_key
"external_authorization:user-#{@user.id}:label-#{@label}"
end
end
end
end
end
module EE
module Gitlab
module ExternalAuthorization
class Client
REQUEST_HEADERS = {
'Content-Type' => 'application/json',
'Accept' => 'application/json'
}.freeze
TIMEOUT = 0.5
def self.build(user, label)
new(
::EE::Gitlab::ExternalAuthorization.service_url,
user,
label
)
end
def initialize(url, user, label)
@url, @user, @label = url, user, label
end
def request_access
response = Excon.post(
@url,
headers: REQUEST_HEADERS,
body: body.to_json,
connect_timeout: TIMEOUT,
read_timeout: TIMEOUT,
write_timeout: TIMEOUT
)
EE::Gitlab::ExternalAuthorization::Response.new(response)
rescue Excon::Error => e
raise EE::Gitlab::ExternalAuthorization::RequestFailed.new(e)
end
private
def body
@body ||= begin
body = {
user_identifier: @user.email,
project_classification_label: @label
}
if @user.ldap_identity
body[:user_ldap_dn] = @user.ldap_identity.extern_uid
end
body
end
end
end
end
end
end
module EE
module Gitlab
module ExternalAuthorization
class Response
include ::Gitlab::Utils::StrongMemoize
def initialize(excon_response)
@excon_response = excon_response
end
def valid?
@excon_response && [200, 401].include?(@excon_response.status)
end
def successful?
valid? && @excon_response.status == 200
end
def reason
parsed_response['reason'] if parsed_response
end
private
def parsed_response
strong_memoize(:parsed_response) { parse_response! }
end
def parse_response!
JSON.parse(@excon_response.body)
rescue JSON::JSONError
# The JSON response is optional, so don't fail when it's missing
nil
end
end
end
end
end
......@@ -47,38 +47,50 @@ describe Admin::ApplicationSettingsController do
expect(ApplicationSetting.current.elasticsearch_url).to contain_exactly(settings[:elasticsearch_url])
end
it 'does not update mirror settings when repository mirrors unlicensed' do
stub_licensed_features(repository_mirrors: false)
shared_examples 'settings for licensed features' do
it 'does not update settings when licesed feature is not available' do
stub_licensed_features(feature => false)
attribute_names = settings.keys.map(&:to_s)
settings = {
mirror_max_delay: 12,
mirror_max_capacity: 2,
mirror_capacity_threshold: 2
}
expect { put :update, application_setting: settings }
.not_to change { ApplicationSetting.current.reload.attributes.slice(*attribute_names) }
end
it 'updates settings when the feature is available' do
stub_licensed_features(feature => true)
settings.each do |setting, _value|
expect do
put :update, application_setting: settings
end.not_to change(ApplicationSetting.current.reload, setting)
settings.each do |attribute, value|
expect(ApplicationSetting.current.public_send(attribute)).to eq(value)
end
end
end
it 'updates mirror settings when repository mirrors is licensed' do
stub_licensed_features(repository_mirrors: true)
mirror_delay = (Gitlab::Mirror.min_delay_upper_bound / 60) + 1
settings = {
mirror_max_delay: mirror_delay,
mirror_max_capacity: 2,
context 'mirror settings' do
let(:settings) do
{
mirror_max_delay: (Gitlab::Mirror.min_delay_upper_bound / 60) + 1,
mirror_max_capacity: 200,
mirror_capacity_threshold: 2
}
end
let(:feature) { :repository_mirrors }
put :update, application_setting: settings
it_behaves_like 'settings for licensed features'
end
settings.each do |setting, value|
expect(ApplicationSetting.current.public_send(setting)).to eq(value)
context 'external policy classification settings' do
let(:settings) do
{
external_authorization_service_enabled: true,
external_authorization_service_url: 'https://custom.service/',
external_authorization_service_default_label: 'default'
}
end
let(:feature) { :external_authorization_service }
it_behaves_like 'settings for licensed features'
end
it 'updates the default_project_creation for string value' do
......
require 'spec_helper'
describe Boards::IssuesController do
include ExternalAuthorizationServiceHelpers
let(:group) { create(:group) }
let(:project_1) { create(:project, namespace: group) }
let(:project_2) { create(:project, namespace: group) }
......@@ -77,6 +79,7 @@ describe Boards::IssuesController do
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
......@@ -87,6 +90,26 @@ describe Boards::IssuesController do
end
end
context 'with external authorization' do
before do
sign_in(user)
enable_external_authorization_service
end
it 'returns a 404 for group boards' do
get :index, board_id: board
expect(response).to have_gitlab_http_status(404)
end
it 'is successful for project boards' do
project_board = create(:board, project: project_1)
list_issues(user: user, board: project_board)
expect(response).to have_gitlab_http_status(200)
end
end
def list_issues(user:, board:, list: nil)
sign_in(user)
......
require 'spec_helper'
describe Dashboard::GroupsController do
include ExternalAuthorizationServiceHelpers
before do
sign_in create(:user)
end
describe '#index' do
it 'works when the external authorization service is enabled' do
enable_external_authorization_service
get :index
expect(response).to have_gitlab_http_status(200)
end
end
end
require 'spec_helper'
describe Dashboard::LabelsController do
before do
sign_in create(:user)
end
describe '#index' do
subject { get :index, format: :json }
it_behaves_like 'disabled when using an external authorization service'
end
end
require 'spec_helper'
describe Dashboard::MilestonesController do
before do
sign_in create(:user)
end
describe '#index' do
subject { get :index }
it_behaves_like 'disabled when using an external authorization service'
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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