Commit db18993f authored by Mayra Cabrera's avatar Mayra Cabrera

Create barebones for Deploytoken

Includes:
- Model, factories, create service and controller actions
- As usual, includes specs for everything
- Builds UI (copy from PAT)
- Add revoke action

Closes #31591
parent aade8b36
......@@ -6,6 +6,7 @@ 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';
document.addEventListener('DOMContentLoaded', () => {
new ProtectedTagCreate();
......@@ -14,4 +15,5 @@ document.addEventListener('DOMContentLoaded', () => {
initSettingsPanels();
new ProtectedBranchCreate(); // eslint-disable-line no-new
new ProtectedBranchEditList(); // eslint-disable-line no-new
new DueDateSelectors();
});
......@@ -284,3 +284,22 @@
.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;
}
.help-block {
margin-top: 4px;
}
}
class Projects::DeployTokensController < Projects::ApplicationController
before_action :authorize_admin_project!
def create
@token = DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute
token_params = {}
if @token.valid?
flash[:notice] = 'Your new project deploy token has been created.'
else
token_params = @token.attributes.slice("name", "scopes", "expires_at")
flash[:alert] = @token.errors.full_messages.join(', ').html_safe
end
redirect_to project_settings_repository_path(project, deploy_token: token_params)
end
def revoke
@token = @project.deploy_tokens.find(params[:id])
@token.revoke!
redirect_to project_settings_repository_path(project)
end
private
def deploy_token_params
params.require(:deploy_token).permit(:name, :expires_at, scopes: [])
end
def authorize_admin_project!
return render_404 unless can?(current_user, :admin_project, @project)
end
end
......@@ -5,7 +5,9 @@ module Projects
def show
@deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
@deploy_tokens = DeployTokensPresenter.new(@project.deploy_tokens.active, current_user: current_user, project: project)
define_deploy_token
define_protected_refs
end
......@@ -51,6 +53,14 @@ module Projects
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
end
def define_deploy_token
@deploy_token = @project.deploy_tokens.build(deploy_token_attributes)
end
def deploy_token_attributes
params.fetch(:deploy_token, {}).permit(:name, :expires_at, scopes: [])
end
end
end
end
class DeployToken < ActiveRecord::Base
include Expirable
include TokenAuthenticatable
add_authentication_token_field :token
AVAILABLE_SCOPES = %w(read_repo read_registry).freeze
serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
validates :scopes, presence: true
belongs_to :project
before_save :ensure_token
scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") }
def revoke!
update!(revoked: true)
end
def self.redis_shared_state_key(user_id)
"gitlab:personal_access_token:#{user_id}"
end
end
......@@ -222,6 +222,7 @@ class Project < ActiveRecord::Base
has_many :environments
has_many :deployments
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :deploy_tokens
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
......
module Projects
module Settings
class DeployTokensPresenter < Gitlab::View::Presenter::Simple
include Enumerable
presents :deploy_tokens
def available_scopes
DeployToken::AVAILABLE_SCOPES
end
def length
deploy_tokens.length
end
def scope_description(scope)
scope_descriptions[scope]
end
def each
deploy_tokens.each do |deploy_token|
yield deploy_token
end
end
def new_deploy_token
@new_deploy_token ||= Gitlab::Redis::SharedState.with do |redis|
token = redis.get(deploy_token_key)
redis.del(deploy_token_key)
token
end
end
private
def scope_descriptions
{
'read_repo' => 'Allows read-only access to the repository',
'read_registry' => 'Allows read-only access to the registry images'
}
end
def deploy_token_key
DeployToken.redis_shared_state_key(current_user.id)
end
end
end
end
module DeployTokens
class CreateService < BaseService
REDIS_EXPIRY_TIME = 3.minutes
def execute
@deploy_token = @project.deploy_tokens.create(params)
store_in_redis if @deploy_token.persisted?
@deploy_token
end
private
def store_in_redis
Gitlab::Redis::SharedState.with do |redis|
redis.set(deploy_token_key, @deploy_token.token, ex: REDIS_EXPIRY_TIME)
end
end
def deploy_token_key
DeployToken.redis_shared_state_key(current_user.id)
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
Pick a name for the application, and we'll give you a unique deploy token.
= form_for token, url: project_deploy_tokens_path(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"
.form-group
= f.label :scopes, class: 'label-light'
- presenter.available_scopes.each do |scope|
= render 'projects/deploy_tokens/scope_form', token: token, scope: scope, presenter: presenter
.prepend-top-default
= f.submit "Create deploy token", class: "btn btn-create"
- expanded = Rails.env.test?
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Deploy Tokens
%button.btn.js-settings-toggle.qa-expand-deploy-keys
= expanded ? 'Collapse' : 'Expand'
%p
Deploy tokens allow read-only access to your repository and registry images.
.settings-content
- if @deploy_tokens.new_deploy_token
= render 'projects/deploy_tokens/new_deploy_token', new_token: @deploy_tokens.new_deploy_token
%h5.prepend-top-0
Add a deploy token
= render 'projects/deploy_tokens/form', project: @project, token: @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
Your New Deploy Token
.form-group
= text_field_tag 'deploy-token', new_token, readonly: true, class: "deploy-token-field form-control js-select-on-focus", 'aria-describedby' => "deploy-token-help-block"
= clipboard_button(text: new_token, title: "Copy deploy token to clipboard", placement: "left")
%span.deploy-token.help-block.text-danger 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
Revoke
%b #{token.name}?
%button.close.pull-right{ "aria-label" => "Close", data: { dismiss: "modal"} }
%span{ "aria-hidden" => "true" } ×
.modal-body
%p
Are you sure you want to revoke this Deploy Token? This action cannot be undone
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
= link_to "Revoke #{token.name}", revoke_project_deploy_token_path(project, token), method: :put, class: 'btn btn-danger'
%fieldset
= check_box_tag "deploy_token[scopes][]", scope, token.scopes.include?(scope), id: "deploy_token_scopes_#{scope}"
= label_tag ("deploy_token_scopes_#{scope}"), scope
%span= presenter.scope_description(scope)
%h5 Active Deploy Tokens (#{active_tokens.length})
- if active_tokens.present?
.table-responsive.deploy-tokens
%table.table
%thead
%tr
%th Name
%th Created
%th Expires
%th Scopes
%th
%tbody
- active_tokens.each do |token|
%tr
%td= token.name
%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 "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
This project has no active Deploy Tokens.
......@@ -9,3 +9,4 @@
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
= render @deploy_keys
= render "projects/deploy_tokens/index"
---
title: Creates Deploy Tokens to allow permanent access to repo and registry
merge_request: 17894
author:
type: added
......@@ -88,6 +88,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resources :deploy_tokens, constraints: { id: /\d+/ }, only: :create do
member do
put :revoke
end
end
resources :forks, only: [:index, :new, :create]
resource :import, only: [:new, :create, :show]
......
class CreateDeployTokens < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :deploy_tokens do |t|
t.references :project, index: true, foreign_key: true, null: false
t.string :name, null: false
t.string :token, index: { unique: true }, null: false
t.string :scopes
t.boolean :revoked, default: false
t.datetime :expires_at
t.timestamps null: false
end
end
end
......@@ -683,6 +683,20 @@ ActiveRecord::Schema.define(version: 20180405101928) do
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.integer "project_id", null: false
t.string "name", null: false
t.string "token", null: false
t.string "scopes"
t.boolean "revoked", default: false
t.datetime "expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "deploy_tokens", ["project_id"], name: "index_deploy_tokens_on_project_id", 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
......@@ -2072,6 +2086,7 @@ ActiveRecord::Schema.define(version: 20180405101928) do
add_foreign_key "clusters_applications_runners", "clusters", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade
add_foreign_key "deploy_tokens", "projects"
add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade
add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade
add_foreign_key "events", "projects", on_delete: :cascade
......
require 'spec_helper'
describe Projects::DeployTokensController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let!(:member) { project.add_master(user) }
before do
sign_in(user)
end
describe 'POST #create' do
let(:deploy_token_params) { attributes_for(:deploy_token) }
subject do
post :create,
namespace_id: project.namespace,
project_id: project,
deploy_token: deploy_token_params
end
context 'with valid params' do
it 'should create a new DeployToken' do
expect { subject }.to change(DeployToken, :count).by(1)
end
it 'should include a flash notice' do
subject
expect(flash[:notice]).to eq('Your new project deploy token has been created.')
end
end
context 'with invalid params' do
let(:deploy_token_params) { attributes_for(:deploy_token, scopes: []) }
it 'should not create a new DeployToken' do
expect { subject }.not_to change(DeployToken, :count)
end
it 'should include a flash alert with the error message' do
subject
expect(flash[:alert]).to eq("Scopes can't be blank")
end
end
context 'when user does not have enough permissions' do
let!(:member) { project.add_developer(user) }
it 'responds with status 404' do
subject
expect(response).to have_gitlab_http_status(404)
end
end
end
end
......@@ -16,5 +16,31 @@ describe Projects::Settings::RepositoryController do
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:show)
end
context 'with no deploy token params' do
it 'should build an empty instance of DeployToken' do
get :show, namespace_id: project.namespace, project_id: project
deploy_token = assigns(:deploy_token)
expect(deploy_token).to be_an_instance_of(DeployToken)
expect(deploy_token.name).to be_nil
expect(deploy_token.expires_at).to be_nil
expect(deploy_token.scopes).to eq([])
end
end
context 'when rendering an invalid deploy token' do
let(:deploy_token_attributes) { attributes_for(:deploy_token, project_id: project.id) }
it 'should build an instance of DeployToken' do
get :show, namespace_id: project.namespace, project_id: project, deploy_token: deploy_token_attributes
deploy_token = assigns(:deploy_token)
expect(deploy_token).to be_an_instance_of(DeployToken)
expect(deploy_token.name).to eq(deploy_token_attributes[:name])
expect(deploy_token.expires_at.to_date).to eq(deploy_token_attributes[:expires_at].to_date)
expect(deploy_token.scopes).to match_array(deploy_token_attributes[:scopes])
end
end
end
end
FactoryBot.define do
factory :deploy_token do
project
token { SecureRandom.hex(50) }
sequence(:name) { |n| "PDT #{n}" }
revoked false
expires_at { 5.days.from_now }
scopes %w(read_repo read_registry)
trait :revoked do
revoked true
end
trait :read_repo do
scopes ['read_repo']
end
trait :read_registry do
scopes ['read_registry']
end
end
end
......@@ -281,6 +281,7 @@ project:
- project_badges
- source_of_merge_requests
- internal_ids
- deploy_tokens
award_emoji:
- awardable
- user
......
require 'spec_helper'
describe DeployToken do
it { is_expected.to belong_to :project }
describe 'validations' do
let(:project_deploy_token) { build(:deploy_token) }
context 'with no scopes defined' do
it 'should not be valid' do
project_deploy_token.scopes = []
expect(project_deploy_token).not_to be_valid
expect(project_deploy_token.errors[:scopes].first).to eq("can't be blank")
end
end
end
describe '#ensure_token' do
let(:project_deploy_token) { build(:deploy_token) }
it 'should ensure a token' do
project_deploy_token.token = nil
project_deploy_token.save
expect(project_deploy_token.token).not_to be_empty
end
end
describe '#revoke!' do
subject { create(:deploy_token) }
it 'should update revoke attribute' do
subject.revoke!
expect(subject.revoked?).to be_truthy
end
end
end
require 'spec_helper'
describe Projects::Settings::DeployTokensPresenter do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:deploy_tokens) { create_list(:deploy_token, 3, project: project) }
subject(:presenter) { described_class.new(deploy_tokens, current_user: user, project: project) }
describe '#available_scopes' do
it 'returns the all the deploy token scopes' do
expect(presenter.available_scopes).to match_array(%w(read_repo read_registry))
end
end
describe '#scope_description' do
let(:deploy_token) { create(:deploy_token, project: project, scopes: [:read_registry]) }
it 'returns the description for a given scope' do
description = 'Allows read-only access to the registry images'
expect(presenter.scope_description('read_registry')).to eq(description)
end
end
describe '#length' do
it 'returns the size of deploy tokens presented' do
expect(presenter.length).to eq(3)
end
end
describe '#new_deploy_token' do
context 'when a deploy token has been created recently' do
it 'returns the token of the deploy' do
deploy_token = ::DeployTokens::CreateService.new(project, user, attributes_for(:deploy_token)).execute
expect(presenter.new_deploy_token).to eq(deploy_token.token)
end
end
context 'when a deploy token has not been created recently' do
it 'does returns nil' do
expect(presenter.new_deploy_token).to be_nil
end
end
end
end
require 'spec_helper'
describe DeployTokens::CreateService, :clean_gitlab_redis_shared_state do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:deploy_token_params) { attributes_for(:deploy_token) }
describe '#execute' do
subject { described_class.new(project, user, deploy_token_params) }
context 'when the deploy token is valid' do
it 'should create a new DeployToken' do
expect { subject.execute }.to change { DeployToken.count }.by(1)
end
it 'should assign the DeployToken to the project' do
subject.execute
expect(subject.project).to eq(project)
end
it 'should store the token on redis' do
subject.execute
redis_key = DeployToken.redis_shared_state_key(user.id)
expect(Gitlab::Redis::SharedState.with { |redis| redis.get(redis_key) }).not_to be_nil
end
end
context 'when the deploy token is invalid' do
let(:deploy_token_params) { attributes_for(:deploy_token, scopes: []) }
it 'it should not create a new DeployToken' do
expect { subject.execute }.not_to change { DeployToken.count }
end
it 'should not store the token on redis' do
subject.execute
redis_key = DeployToken.redis_shared_state_key(user.id)
expect(Gitlab::Redis::SharedState.with { |redis| redis.get(redis_key) }).to be_nil
end
end
end
end
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment