Commit dd552d06 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '31591-project-deploy-tokens-to-allow-permanent-access' into 'master'

Create Project Deploy Tokens to allow permanent access to repo and registry

Closes #31591

See merge request gitlab-org/gitlab-ce!17894
parents 671e93dc b38439a3
import initForm from '../form';
document.addEventListener('DOMContentLoaded', initForm);
/* eslint-disable no-new */
import ProtectedTagCreate from '~/protected_tags/protected_tag_create';
import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
import initSettingsPanels from '~/settings_panels';
import initDeployKeys from '~/deploy_keys';
import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
import DueDateSelectors from '~/due_date_select';
export default () => {
new ProtectedTagCreate();
new ProtectedTagEditList();
initDeployKeys();
initSettingsPanels();
new ProtectedBranchCreate(); // eslint-disable-line no-new
new ProtectedBranchEditList(); // eslint-disable-line no-new
new DueDateSelectors();
};
/* eslint-disable no-new */
import initForm from '../form';
import ProtectedTagCreate from '~/protected_tags/protected_tag_create';
import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
import initSettingsPanels from '~/settings_panels';
import initDeployKeys from '~/deploy_keys';
import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
document.addEventListener('DOMContentLoaded', () => {
new ProtectedTagCreate();
new ProtectedTagEditList();
initDeployKeys();
initSettingsPanels();
new ProtectedBranchCreate(); // eslint-disable-line no-new
new ProtectedBranchEditList(); // eslint-disable-line no-new
});
document.addEventListener('DOMContentLoaded', initForm);
......@@ -284,3 +284,23 @@
.deprecated-service {
cursor: default;
}
.personal-access-tokens-never-expires-label {
color: $note-disabled-comment-color;
}
.created-deploy-token-container {
.deploy-token-field {
width: 90%;
display: inline;
}
.btn-clipboard {
margin-left: 5px;
}
.deploy-token-help-block {
display: block;
margin-bottom: 0;
}
}
......@@ -25,8 +25,7 @@ def authenticate_project_or_user
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
if @authentication_result.failed? ||
(@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
if @authentication_result.failed?
render_unauthorized
end
end
......
class Projects::DeployTokensController < Projects::ApplicationController
before_action :authorize_admin_project!
def revoke
@token = @project.deploy_tokens.find(params[:id])
@token.revoke!
redirect_to project_settings_repository_path(project)
end
end
......@@ -4,13 +4,31 @@ class RepositoryController < Projects::ApplicationController
before_action :authorize_admin_project!
def show
@deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
render_show
end
define_protected_refs
def create_deploy_token
@new_deploy_token = DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute
if @new_deploy_token.persisted?
flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.')
end
render_show
end
private
def render_show
@deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
@deploy_tokens = @project.deploy_tokens.active
define_deploy_token
define_protected_refs
render 'show'
end
def define_protected_refs
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
@protected_tags = @project.protected_tags.order(:name).page(params[:page])
......@@ -51,6 +69,14 @@ def load_gon_index
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
end
def define_deploy_token
@new_deploy_token ||= DeployToken.new
end
def deploy_token_params
params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry)
end
end
end
end
module DeployTokensHelper
def expand_deploy_tokens_section?(deploy_token)
deploy_token.persisted? ||
deploy_token.errors.present? ||
Rails.env.test?
end
def container_registry_enabled?(project)
Gitlab.config.registry.enabled &&
can?(current_user, :read_container_image, project)
end
end
class DeployToken < ActiveRecord::Base
include Expirable
include TokenAuthenticatable
add_authentication_token_field :token
AVAILABLE_SCOPES = %i(read_repository read_registry).freeze
default_value_for(:expires_at) { Forever.date }
has_many :project_deploy_tokens, inverse_of: :deploy_token
has_many :projects, through: :project_deploy_tokens
validate :ensure_at_least_one_scope
before_save :ensure_token
accepts_nested_attributes_for :project_deploy_tokens
scope :active, -> { where("revoked = false AND expires_at >= NOW()") }
def revoke!
update!(revoked: true)
end
def active?
!revoked
end
def scopes
AVAILABLE_SCOPES.select { |token_scope| read_attribute(token_scope) }
end
def username
"gitlab+deploy-token-#{id}"
end
def has_access_to?(requested_project)
project == requested_project
end
# This is temporal. Currently we limit DeployToken
# to a single project, later we're going to extend
# that to be for multiple projects and namespaces.
def project
projects.first
end
def expires_at
expires_at = read_attribute(:expires_at)
expires_at != Forever.date ? expires_at : nil
end
def expires_at=(value)
write_attribute(:expires_at, value.presence || Forever.date)
end
private
def ensure_at_least_one_scope
errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
end
end
......@@ -222,6 +222,8 @@ class Project < ActiveRecord::Base
has_many :environments
has_many :deployments
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
......
class ProjectDeployToken < ActiveRecord::Base
belongs_to :project
belongs_to :deploy_token, inverse_of: :project_deploy_tokens
validates :deploy_token, presence: true
validates :project, presence: true
validates :deploy_token_id, uniqueness: { scope: [:project_id] }
end
class DeployTokenPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:master) { @subject.project.team.master?(@user) }
rule { anonymous }.prevent_all
rule { master }.policy do
enable :create_deploy_token
enable :update_deploy_token
end
end
......@@ -143,7 +143,7 @@ def self.create_read_update_admin(name)
end
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separatly.
# that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code
rule { guest & can?(:read_container_image) }.enable :build_read_container_image
......
......@@ -109,7 +109,7 @@ def can_access?(requested_project, requested_action)
case requested_action
when 'pull'
build_can_pull?(requested_project) || user_can_pull?(requested_project)
build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
when '*'
......@@ -123,22 +123,33 @@ def registry
Gitlab.config.registry
end
def can_user?(ability, project)
user = current_user.is_a?(User) ? current_user : nil
can?(user, ability, project)
end
def build_can_pull?(requested_project)
# Build can:
# 1. pull from its own project (for ex. a build)
# 2. read images from dependent projects if creator of build is a team member
has_authentication_ability?(:build_read_container_image) &&
(requested_project == project || can?(current_user, :build_read_container_image, requested_project))
(requested_project == project || can_user?(:build_read_container_image, requested_project))
end
def user_can_admin?(requested_project)
has_authentication_ability?(:admin_container_image) &&
can?(current_user, :admin_container_image, requested_project)
can_user?(:admin_container_image, requested_project)
end
def user_can_pull?(requested_project)
has_authentication_ability?(:read_container_image) &&
can?(current_user, :read_container_image, requested_project)
can_user?(:read_container_image, requested_project)
end
def deploy_token_can_pull?(requested_project)
has_authentication_ability?(:read_container_image) &&
current_user.is_a?(DeployToken) &&
current_user.has_access_to?(requested_project)
end
##
......@@ -154,7 +165,7 @@ def build_can_push?(requested_project)
def user_can_push?(requested_project)
has_authentication_ability?(:create_container_image) &&
can?(current_user, :create_container_image, requested_project)
can_user?(:create_container_image, requested_project)
end
def error(code, status:, message: '')
......
module DeployTokens
class CreateService < BaseService
def execute
@project.deploy_tokens.create(params)
end
end
end
......@@ -2,7 +2,6 @@
- page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
......
%p.profile-settings-content
= s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.")
= form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project), method: :post do |f|
= form_errors(token)
.form-group
= f.label :name, class: 'label-light'
= f.text_field :name, class: 'form-control', required: true
.form-group
= f.label :expires_at, class: 'label-light'
= f.text_field :expires_at, class: 'datepicker form-control', value: f.object.expires_at
.form-group
= f.label :scopes, class: 'label-light'
%fieldset
= f.check_box :read_repository
= label_tag ("deploy_token_read_repository"), 'read_repository'
%span= s_('DeployTokens|Allows read-only access to the repository')
- if container_registry_enabled?(project)
%fieldset
= f.check_box :read_registry
= label_tag ("deploy_token_read_registry"), 'read_registry'
%span= s_('DeployTokens|Allows read-only access to the registry images')
.prepend-top-default
= f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success'
- expanded = expand_deploy_tokens_section?(@new_deploy_token)
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4= s_('DeployTokens|Deploy Tokens')
%button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
= s_('DeployTokens|Deploy tokens allow read-only access to your repository and registry images.')
.settings-content
- if @new_deploy_token.persisted?
= render 'projects/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token
- else
%h5.prepend-top-0
= s_('DeployTokens|Add a deploy token')
= render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens
%hr
= render 'projects/deploy_tokens/table', project: @project, active_tokens: @deploy_tokens
.created-deploy-token-container
%h5.prepend-top-0
= s_('DeployTokens|Your New Deploy Token')
.form-group
= text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
= clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left')
%span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.")
.form-group
= text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
= clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left')
%span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.")
%hr
.modal{ id: "revoke-modal-#{token.id}" }
.modal-dialog
.modal-content
.modal-header
%h4.modal-title.pull-left
= s_('DeployTokens|Revoke')
%b #{token.name}?
%button.close{ 'aria-label' => _('Close'), 'data-dismiss' => 'modal', type: 'button' }
%span{ 'aria-hidden' => 'true' } &times;
.modal-body
%p
= s_('DeployTokens|You are about to revoke')
%b #{token.name}.
= s_('DeployTokens|This action cannot be undone.')
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel')
= link_to s_('DeployTokens|Revoke %{name}') % { name: token.name }, revoke_project_deploy_token_path(project, token), method: :put, class: 'btn btn-danger'
%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length }
- if active_tokens.present?
.table-responsive.deploy-tokens
%table.table
%thead
%tr
%th= s_('DeployTokens|Name')
%th= s_('DeployTokens|Username')
%th= s_('DeployTokens|Created')
%th= s_('DeployTokens|Expires')
%th= s_('DeployTokens|Scopes')
%th
%tbody
- active_tokens.each do |token|
%tr
%td= token.name
%td= token.username
%td= token.created_at.to_date.to_s(:medium)
%td
- if token.expires?
%span{ class: ('text-warning' if token.expires_soon?) }
In #{distance_of_time_in_words_to_now(token.expires_at)}
- else
%span.token-never-expires-label Never
%td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>"
%td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger pull-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"}
= render 'projects/deploy_tokens/revoke_modal', token: token, project: project
- else
.settings-message.text-center
= s_('DeployTokens|This project has no active Deploy Tokens.')
......@@ -28,6 +28,10 @@
%pre
docker login #{Gitlab.config.registry.host_port}
%br
%p
- deploy_token = link_to(_('deploy token'), help_page_path('user/projects/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank')
= s_('ContainerRegistry|You can also %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token }
%br
%p
= s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
%pre
......
......@@ -9,3 +9,4 @@
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
= render @deploy_keys
= render "projects/deploy_tokens/index"
---
title: Create Deploy Tokens to allow permanent access to repository and registry
merge_request: 17894
author:
type: added
......@@ -88,6 +88,12 @@
end
end
resources :deploy_tokens, constraints: { id: /\d+/ }, only: [] do
member do
put :revoke
end
end
resources :forks, only: [:index, :new, :create]
resource :import, only: [:new, :create, :show]
......@@ -426,7 +432,9 @@
post :reset_cache
end
resource :integrations, only: [:show]
resource :repository, only: [:show], controller: :repository
resource :repository, only: [:show], controller: :repository do
post :create_deploy_token, path: 'deploy_token/create'
end
end
# Since both wiki and repository routing contains wildcard characters
......
class CreateDeployTokens < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :deploy_tokens do |t|
t.boolean :revoked, default: false
t.boolean :read_repository, null: false, default: false
t.boolean :read_registry, null: false, default: false
t.datetime_with_timezone :expires_at, null: false
t.datetime_with_timezone :created_at, null: false
t.string :name, null: false
t.string :token, index: { unique: true }, null: false
t.index [:token, :expires_at, :id], where: "(revoked IS FALSE)"
end
end
end
class CreateProjectDeployTokens < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :project_deploy_tokens do |t|
t.integer :project_id, null: false
t.integer :deploy_token_id, null: false
t.datetime_with_timezone :created_at, null: false
t.foreign_key :deploy_tokens, column: :deploy_token_id, on_delete: :cascade
t.foreign_key :projects, column: :project_id, on_delete: :cascade
t.index [:project_id, :deploy_token_id], unique: true
end
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180405101928) do
ActiveRecord::Schema.define(version: 20180405142733) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -683,6 +683,19 @@
add_index "deploy_keys_projects", ["project_id"], name: "index_deploy_keys_projects_on_project_id", using: :btree
create_table "deploy_tokens", force: :cascade do |t|
t.boolean "revoked", default: false
t.boolean "read_repository", default: false, null: false
t.boolean "read_registry", default: false, null: false
t.datetime_with_timezone "expires_at", null: false
t.datetime_with_timezone "created_at", null: false
t.string "name", null: false
t.string "token", null: false
end
add_index "deploy_tokens", ["token", "expires_at", "id"], name: "index_deploy_tokens_on_token_and_expires_at_and_id", where: "(revoked IS FALSE)", using: :btree
add_index "deploy_tokens", ["token"], name: "index_deploy_tokens_on_token", unique: true, using: :btree
create_table "deployments", force: :cascade do |t|
t.integer "iid", null: false
t.integer "project_id", null: false
......@@ -1430,6 +1443,14 @@
add_index "project_custom_attributes", ["key", "value"], name: "index_project_custom_attributes_on_key_and_value", using: :btree
add_index "project_custom_attributes", ["project_id", "key"], name: "index_project_custom_attributes_on_project_id_and_key", unique: true, using: :btree
create_table "project_deploy_tokens", force: :cascade do |t|
t.integer "project_id", null: false
t.integer "deploy_token_id", null: false
t.datetime_with_timezone "created_at", null: false
end
add_index "project_deploy_tokens", ["project_id", "deploy_token_id"], name: "index_project_deploy_tokens_on_project_id_and_deploy_token_id", unique: true, using: :btree
create_table "project_features", force: :cascade do |t|
t.integer "project_id"
t.integer "merge_requests_access_level"
......@@ -2137,6 +2158,8 @@
add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "project_auto_devops", "projects", on_delete: :cascade
add_foreign_key "project_custom_attributes", "projects", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "projects", on_delete: :cascade
add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade