Commit c84b60b1 authored by Tuomo Ala-Vannesluoma's avatar Tuomo Ala-Vannesluoma Committed by Nick Thomas

Make GitLab pages support access control

parent c972f2e4
......@@ -52,6 +52,21 @@
required: false,
default: '',
},
pagesAvailable: {
type: Boolean,
required: false,
default: false,
},
pagesAccessControlEnabled: {
type: Boolean,
required: false,
default: false,
},
pagesHelpPath: {
type: String,
required: false,
default: '',
},
},
data() {
......@@ -64,6 +79,7 @@
buildsAccessLevel: 20,
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
pagesAccessLevel: 20,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
......@@ -90,6 +106,13 @@
);
},
pagesFeatureAccessLevelOptions() {
if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
return this.featureAccessLevelOptions.concat([[30, 'Everyone']]);
}
return this.featureAccessLevelOptions;
},
repositoryEnabled() {
return this.repositoryAccessLevel > 0;
},
......@@ -109,6 +132,10 @@
this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
if (this.pagesAccessLevel === 20) {
// When from Internal->Private narrow access for only members
this.pagesAccessLevel = 10;
}
this.highlightChanges();
} else if (oldValue === visibilityOptions.PRIVATE) {
// if changing away from private, make enabled features more permissive
......@@ -118,6 +145,7 @@
if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
if (this.pagesAccessLevel === 10) this.pagesAccessLevel = 20;
this.highlightChanges();
}
},
......@@ -323,6 +351,18 @@
name="project[project_feature_attributes][snippets_access_level]"
/>
</project-setting-row>
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
:help-path="pagesHelpPath"
label="Pages"
help-text="Static website for the project."
>
<project-feature-setting
v-model="pagesAccessLevel"
:options="pagesFeatureAccessLevelOptions"
name="project[project_feature_attributes][pages_access_level]"
/>
</project-setting-row>
</div>
</div>
</template>
......@@ -366,6 +366,7 @@ class ProjectsController < Projects::ApplicationController
repository_access_level
snippets_access_level
wiki_access_level
pages_access_level
]
]
end
......
......@@ -16,7 +16,7 @@ module Types
:create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
:create_pages, :destroy_pages
:create_pages, :destroy_pages, :read_pages_content
end
end
end
......@@ -454,6 +454,7 @@ module ProjectsHelper
buildsAccessLevel: feature.builds_access_level,
wikiAccessLevel: feature.wiki_access_level,
snippetsAccessLevel: feature.snippets_access_level,
pagesAccessLevel: feature.pages_access_level,
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled
}
......@@ -468,7 +469,10 @@ module ProjectsHelper
registryAvailable: Gitlab.config.registry.enabled,
registryHelpPath: help_page_path('user/project/container_registry'),
lfsAvailable: Gitlab.config.lfs.enabled,
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'),
pagesAvailable: Gitlab.config.pages.enabled,
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
pagesHelpPath: help_page_path('user/project/pages/index.md')
}
end
......
......@@ -55,8 +55,8 @@ class Project < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :issues_enabled?, to: :project_feature,
allow_nil: true
:merge_requests_enabled?, :issues_enabled?, :pages_enabled?, :public_pages?,
to: :project_feature, allow_nil: true
delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage
......@@ -356,7 +356,7 @@ class Project < ActiveRecord::Base
# "enabled" here means "not disabled". It includes private features!
scope :with_feature_enabled, ->(feature) {
access_level_attribute = ProjectFeature.access_level_attribute(feature)
with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED] })
with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] })
}
# Picks a feature where the level is exactly that given.
......@@ -418,15 +418,15 @@ class Project < ActiveRecord::Base
end
end
# project features may be "disabled", "internal" or "enabled". If "internal",
# project features may be "disabled", "internal", "enabled" or "public". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either enabled, or internal with permission for the user.
# the feature is either public, enabled, or internal with permission for the user.
#
# This method uses an optimised version of `with_feature_access_level` for
# logged in users to more efficiently get private projects with the given
# feature.
def self.with_feature_available_for_user(feature, user)
visible = [nil, ProjectFeature::ENABLED]
visible = [nil, ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
if user&.admin?
with_feature_enabled(feature)
......
......@@ -13,14 +13,16 @@ class ProjectFeature < ActiveRecord::Base
# Disabled: not enabled for anyone
# Private: enabled only for team members
# Enabled: enabled for everyone able to access the project
# Public: enabled for everyone (only allowed for pages)
#
# Permission levels
DISABLED = 0
PRIVATE = 10
ENABLED = 20
PUBLIC = 30
FEATURES = %i(issues merge_requests wiki snippets builds repository).freeze
FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze
class << self
def access_level_attribute(feature)
......@@ -46,6 +48,7 @@ class ProjectFeature < ActiveRecord::Base
validates :project, presence: true
validate :repository_children_level
validate :allowed_access_levels
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
default_value_for :issues_access_level, value: ENABLED, allows_nil: false
......@@ -81,6 +84,16 @@ class ProjectFeature < ActiveRecord::Base
issues_access_level > DISABLED
end
def pages_enabled?
pages_access_level > DISABLED
end
def public_pages?
return true unless Gitlab.config.pages.access_control
pages_access_level == PUBLIC || pages_access_level == ENABLED && project.public?
end
private
# Validates builds and merge requests access level
......@@ -95,6 +108,17 @@ class ProjectFeature < ActiveRecord::Base
%i(merge_requests_access_level builds_access_level).each(&validator)
end
# Validates access level for other than pages cannot be PUBLIC
def allowed_access_levels
validator = lambda do |field|
level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > ProjectFeature::ENABLED
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
(FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")}
end
def get_permission(user, level)
case level
when DISABLED
......@@ -103,6 +127,8 @@ class ProjectFeature < ActiveRecord::Base
user && (project.team.member?(user) || user.full_private_access?)
when ENABLED
true
when PUBLIC
true
else
true
end
......
......@@ -110,6 +110,7 @@ class ProjectPolicy < BasePolicy
snippets
wiki
builds
pages
]
features.each do |f|
......@@ -167,6 +168,7 @@ class ProjectPolicy < BasePolicy
enable :upload_file
enable :read_cycle_analytics
enable :award_emoji
enable :read_pages_content
end
# These abilities are not allowed to admins that are not members of the project,
......@@ -286,6 +288,8 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:merge_request))
end
rule { pages_disabled }.prevent :read_pages_content
rule { issues_disabled & merge_requests_disabled }.policy do
prevent(*create_read_update_admin_destroy(:label))
prevent(*create_read_update_admin_destroy(:milestone))
......@@ -345,6 +349,7 @@ class ProjectPolicy < BasePolicy
enable :download_code
enable :download_wiki_code
enable :read_cycle_analytics
enable :read_pages_content
# NOTE: may be overridden by IssuePolicy
enable :read_issue
......
......@@ -21,7 +21,9 @@ module Projects
def pages_config
{
domains: pages_domains_config,
https_only: project.pages_https_only?
https_only: project.pages_https_only?,
id: project.project_id,
access_control: !project.public_pages?
}
end
......@@ -31,7 +33,9 @@ module Projects
domain: domain.domain,
certificate: domain.certificate,
key: domain.key,
https_only: project.pages_https_only? && domain.https?
https_only: project.pages_https_only? && domain.https?,
id: project.project_id,
access_control: !project.public_pages?
}
end
end
......
......@@ -72,7 +72,11 @@ module Projects
system_hook_service.execute_hooks_for(project, :update)
end
update_pages_config if changing_pages_https_only?
update_pages_config if changing_pages_related_config?
end
def changing_pages_related_config?
changing_pages_https_only? || changing_pages_access_level?
end
def update_failed!
......@@ -102,6 +106,10 @@ module Projects
params.dig(:project_feature_attributes, :wiki_access_level).to_i > ProjectFeature::DISABLED
end
def changing_pages_access_level?
params.dig(:project_feature_attributes, :pages_access_level)
end
def ensure_wiki_exists
ProjectWiki.new(project, project.owner).wiki
rescue ProjectWiki::CouldNotCreateWikiError
......
---
title: Add access control to GitLab pages and make it possible to enable/disable it in project settings
merge_request: 18589
author: Tuomo Ala-Vannesluoma
type: added
......@@ -210,6 +210,7 @@ production: &base
## GitLab Pages
pages:
enabled: false
access_control: false
# The location where pages are stored (default: shared/pages).
# path: shared/pages
......
......@@ -200,6 +200,7 @@ Settings.registry['path'] = Settings.absolute(Settings.registry['path
#
Settings['pages'] ||= Settingslogic.new({})
Settings.pages['enabled'] = false if Settings.pages['enabled'].nil?
Settings.pages['access_control'] = false if Settings.pages['access_control'].nil?
Settings.pages['path'] = Settings.absolute(Settings.pages['path'] || File.join(Settings.shared['path'], "pages"))
Settings.pages['https'] = false if Settings.pages['https'].nil?
Settings.pages['host'] ||= "example.com"
......
class AddPagesAccessLevelToProjectFeature < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_column_with_default(:project_features, :pages_access_level, :integer, default: ProjectFeature::PUBLIC, allow_null: false)
change_column_default(:project_features, :pages_access_level, ProjectFeature::ENABLED)
end
def down
remove_column :project_features, :pages_access_level
end
end
......@@ -1578,6 +1578,7 @@ ActiveRecord::Schema.define(version: 20180924141949) do
t.datetime "created_at"
t.datetime "updated_at"
t.integer "repository_access_level", default: 20, null: false
t.integer "pages_access_level", default: 20, null: false
end
add_index "project_features", ["project_id"], name: "index_project_features_on_project_id", unique: true, using: :btree
......
......@@ -110,6 +110,7 @@ which visibility level you select on project settings.
- Disabled: disabled for everyone
- Only team members: only team members will see even if your project is public or internal
- Everyone with access: everyone can see depending on your project visibility level
- Everyone: enabled for everyone (only available for GitLab Pages)
### Protected branches
......@@ -242,6 +243,7 @@ which visibility level you select on project settings.
- Disabled: disabled for everyone
- Only team members: only team members will see even if your project is public or internal
- Everyone with access: everyone can see depending on your project visibility level
- Everyone: enabled for everyone (only available for GitLab Pages)
## GitLab CI/CD permissions
......
......@@ -287,6 +287,12 @@ module API
present_projects forks
end
desc 'Check pages access of this project'
get ':id/pages_access' do
authorize! :read_pages_content, user_project unless user_project.public_pages?
status 200
end
desc 'Update an existing project' do
success Entities::Project
end
......
require_relative '../support/helpers/test_env'
FactoryBot.define do
PAGES_ACCESS_LEVEL_SCHEMA_VERSION = 20180423204600
# Project without repository
#
# Project does not have bare repository.
......@@ -23,6 +25,7 @@ FactoryBot.define do
issues_access_level ProjectFeature::ENABLED
merge_requests_access_level ProjectFeature::ENABLED
repository_access_level ProjectFeature::ENABLED
pages_access_level ProjectFeature::ENABLED
# we can't assign the delegated `#ci_cd_settings` attributes directly, as the
# `#ci_cd_settings` relation needs to be created first
......@@ -34,13 +37,20 @@ FactoryBot.define do
builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min
merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min
project.project_feature.update(
hash = {
wiki_access_level: evaluator.wiki_access_level,
builds_access_level: builds_access_level,
snippets_access_level: evaluator.snippets_access_level,
issues_access_level: evaluator.issues_access_level,
merge_requests_access_level: merge_requests_access_level,
repository_access_level: evaluator.repository_access_level)
repository_access_level: evaluator.repository_access_level
}
if ActiveRecord::Migrator.current_version >= PAGES_ACCESS_LEVEL_SCHEMA_VERSION
hash.store("pages_access_level", evaluator.pages_access_level)
end
project.project_feature.update(hash)
# Normally the class Projects::CreateService is used for creating
# projects, and this class takes care of making sure the owner and current
......@@ -244,6 +254,10 @@ FactoryBot.define do
trait(:repository_enabled) { repository_access_level ProjectFeature::ENABLED }
trait(:repository_disabled) { repository_access_level ProjectFeature::DISABLED }
trait(:repository_private) { repository_access_level ProjectFeature::PRIVATE }
trait(:pages_public) { pages_access_level ProjectFeature::PUBLIC }
trait(:pages_enabled) { pages_access_level ProjectFeature::ENABLED }
trait(:pages_disabled) { pages_access_level ProjectFeature::DISABLED }
trait(:pages_private) { pages_access_level ProjectFeature::PRIVATE }
trait :auto_devops do
association :auto_devops, factory: :project_auto_devops
......
......@@ -10,7 +10,7 @@ describe Types::PermissionTypes::Project do
:read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label,
:update_wiki, :destroy_wiki, :create_pages, :destroy_pages
:update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content
]
expect(described_class).to have_graphql_fields(expected_permissions)
......
......@@ -492,6 +492,7 @@ ProjectFeature:
- snippets_access_level
- builds_access_level
- repository_access_level
- pages_access_level
- created_at
- updated_at
ProtectedBranch::MergeAccessLevel:
......
require 'spec_helper'
require Rails.root.join('db', 'migrate', '20180423204600_add_pages_access_level_to_project_feature.rb')
describe AddPagesAccessLevelToProjectFeature, :migration do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:features) { table(:project_features) }
let!(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab') }
let!(:first_project) { projects.create(name: 'gitlab1', path: 'gitlab1', namespace_id: namespace.id) }
let!(:first_project_features) { features.create(project_id: first_project.id) }
let!(:second_project) { projects.create(name: 'gitlab2', path: 'gitlab2', namespace_id: namespace.id) }
let!(:second_project_features) { features.create(project_id: second_project.id) }
it 'correctly migrate pages for old projects to be public' do
migrate!
# For old projects pages should be public
expect(first_project_features.reload.pages_access_level).to eq ProjectFeature::PUBLIC
expect(second_project_features.reload.pages_access_level).to eq ProjectFeature::PUBLIC
end
it 'after migration pages are enabled as default' do
migrate!
# For new project default is enabled
third_project = projects.create(name: 'gitlab3', path: 'gitlab3', namespace_id: namespace.id)
third_project_features = features.create(project_id: third_project.id)
expect(third_project_features.reload.pages_access_level).to eq ProjectFeature::ENABLED
end
end
......@@ -65,7 +65,8 @@ describe InternalId do
context 'with an insufficient schema version' do
before do
described_class.reset_column_information
expect(ActiveRecord::Migrator).to receive(:current_version).and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1)
# Project factory will also call the current_version
expect(ActiveRecord::Migrator).to receive(:current_version).twice.and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1)
end
let(:init) { double('block') }
......
......@@ -17,7 +17,7 @@ describe ProjectFeature do
end
describe '#feature_available?' do
let(:features) { %w(issues wiki builds merge_requests snippets repository) }
let(:features) { %w(issues wiki builds merge_requests snippets repository pages) }
context 'when features are disabled' do
it "returns false" do
......@@ -112,6 +112,19 @@ describe ProjectFeature do
end
end
context 'public features' do
it "does not allow public for other than pages" do
features = %w(issues wiki builds merge_requests snippets repository)
project_feature = project.project_feature
features.each do |feature|
field = "#{feature}_access_level".to_sym
project_feature.update_attribute(field, ProjectFeature::PUBLIC)
expect(project_feature.valid?).to be_falsy
end
end
end
describe '#*_enabled?' do
let(:features) { %w(wiki builds merge_requests) }
......
require 'spec_helper'
describe "Internal Project Pages Access" do
using RSpec::Parameterized::TableSyntax
include AccessMatchers
set(:group) { create(:group) }
set(:project) { create(:project, :internal, pages_access_level: ProjectFeature::ENABLED, namespace: group) }
set(:admin) { create(:admin) }
set(:owner) { create(:user) }
set(:master) { create(:user) }
set(:developer) { create(:user) }
set(:reporter) { create(:user) }
set(:guest) { create(:user) }
set(:user) { create(:user) }
before do
allow(Gitlab.config.pages).to receive(:access_control).and_return(true)
group.add_owner(owner)
project.add_master(master)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
describe "Project should be internal" do
describe '#internal?' do
subject { project.internal? }
it { is_expected.to be_truthy }
end
end
describe "GET /projects/:id/pages_access" do
context 'access depends on the level' do
where(:pages_access_level, :with_user, :expected_result) do
ProjectFeature::DISABLED | "admin" | 403
ProjectFeature::DISABLED | "owner" | 403
ProjectFeature::DISABLED | "master" | 403
ProjectFeature::DISABLED | "developer" | 403
ProjectFeature::DISABLED | "reporter" | 403
ProjectFeature::DISABLED | "guest" | 403
ProjectFeature::DISABLED | "user" | 403
ProjectFeature::DISABLED | nil | 404
ProjectFeature::PUBLIC | "admin" | 200
ProjectFeature::PUBLIC | "owner" | 200
ProjectFeature::PUBLIC | "master" | 200
ProjectFeature::PUBLIC | "developer" | 200
ProjectFeature::PUBLIC | "reporter" | 200
ProjectFeature::PUBLIC | "guest" | 200
ProjectFeature::PUBLIC | "user" | 200
ProjectFeature::PUBLIC | nil | 404
ProjectFeature::ENABLED | "admin" | 200
ProjectFeature::ENABLED | "owner" | 200
ProjectFeature::ENABLED | "master" | 200
ProjectFeature::ENABLED | "developer" | 200
ProjectFeature::ENABLED | "reporter" | 200
ProjectFeature::ENABLED | "guest" | 200
ProjectFeature::ENABLED | "user" | 200
ProjectFeature::ENABLED | nil | 404
ProjectFeature::PRIVATE | "admin" | 200
ProjectFeature::PRIVATE | "owner" | 200
ProjectFeature::PRIVATE | "master" | 200
ProjectFeature::PRIVATE | "developer" | 200
ProjectFeature::PRIVATE | "reporter" | 200
ProjectFeature::PRIVATE | "guest" | 200
ProjectFeature::PRIVATE | "user" | 403
ProjectFeature::PRIVATE | nil | 404
end
with_them do
before do
project.project_feature.update(pages_access_level: pages_access_level)
end
it "correct return value" do
if !with_user.nil?
user = public_send(with_user)
get api("/projects/#{project.id}/pages_access", user)
else
get api("/projects/#{project.id}/pages_access")
end
expect(response).to have_gitlab_http_status(expected_result)
end
end
end
end
end
require 'spec_helper'
describe "Private Project Pages Access" do
using RSpec::Parameterized::TableSyntax
include AccessMatchers
set(:group) { create(:group) }
set(:project) { create(:project, :private, pages_access_level: ProjectFeature::ENABLED, namespace: group) }
set(:admin) { create(:admin) }
set(:owner) { create(:user) }
set(:master) { create(:user) }
set(:developer) { create(:user) }
set(:reporter) { create(:user) }
set(:guest) { create(:user) }
set(:user) { create(:user) }
before do
allow(Gitlab.config.pages).to receive(:access_control).and_return(true)
group.add_owner(owner)
project.add_master(master)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
describe "Project should be private" do
describe '#private?' do
subject { project.private? }
it { is_expected.to be_truthy }
end
end
describe "GET /projects/:id/pages_access" do
context 'access depends on the level' do
where(:pages_access_level, :with_user, :expected_result) do
ProjectFeature::DISABLED | "admin" | 403
ProjectFeature::DISABLED | "owner" | 403
ProjectFeature::DISABLED | "master" | 403
ProjectFeature::DISABLED | "developer" | 403
ProjectFeature::DISABLED | "reporter" | 403
ProjectFeature::DISABLED | "guest" | 403
ProjectFeature::DISABLED | "user" | 404
ProjectFeature::DISABLED | nil | 404
ProjectFeature::PUBLIC | "admin" | 200
ProjectFeature::PUBLIC | "owner" | 200
ProjectFeature::PUBLIC | "master" | 200
ProjectFeature::PUBLIC | "developer" | 200
ProjectFeature::PUBLIC | "reporter" | 200
ProjectFeature::PUBLIC | "guest" | 200
ProjectFeature::PUBLIC | "user" | 404
ProjectFeature::PUBLIC | nil | 404
ProjectFeature::ENABLED | "admin" | 200
ProjectFeature::ENABLED | "owner" | 200
ProjectFeature::ENABLED | "master" | 200
ProjectFeature::ENABLED | "developer" | 200
ProjectFeature::ENABLED | "reporter" | 200
ProjectFeature::ENABLED | "guest" | 200
ProjectFeature::ENABLED | "user" | 404
ProjectFeature::ENABLED | nil | 404
ProjectFeature::PRIVATE | "admin" | 200
ProjectFeature::PRIVATE | "owner" | 200
ProjectFeature::PRIVATE | "master" | 200
ProjectFeature::PRIVATE | "developer" | 200
ProjectFeature::PRIVATE | "reporter" | 200
ProjectFeature::PRIVATE | "guest" | 200
ProjectFeature::PRIVATE | "user" | 404
ProjectFeature::PRIVATE | nil | 404
end
with_them do
before do
project.project_feature.update(pages_access_level: pages_access_level)
end
it "correct return value" do
if !with_user.nil?
user = public_send(with_user)
get api("/projects/#{project.id}/pages_access", user)
else
get api("/projects/#{project.id}/pages_access")
end
expect(response).to have_gitlab_http_status(expected_result)
end
end
end
end