Skip to content

feat: implement initial root API token management #470

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### Classes

#### Public Classes

* [`gitlab`](#gitlab): This module installs and configures Gitlab with the Omnibus package.
* [`gitlab::backup`](#gitlab--backup): This class is called from gitlab for backup config.
* [`gitlab::host_config`](#gitlab--host_config): This class is for setting host configurations required for gitlab installation.
Expand All @@ -14,6 +16,10 @@
* [`gitlab::omnibus_package_repository`](#gitlab--omnibus_package_repository): This class is used to configure gitlab repositories
* [`gitlab::service`](#gitlab--service): This class is meant to be called from gitlab. It ensure the service is running.

#### Private Classes

* `gitlab::initial_root_token`: Manages initial root token

### Defined types

* [`gitlab::custom_hook`](#gitlab--custom_hook): Manage custom hook files within a GitLab project.
Expand Down Expand Up @@ -129,6 +135,9 @@ The following parameters are available in the `gitlab` class:
* [`pgpass_file_location`](#-gitlab--pgpass_file_location)
* [`pgpass_file_ensure`](#-gitlab--pgpass_file_ensure)
* [`pgbouncer_password`](#-gitlab--pgbouncer_password)
* [`create_initial_root_token`](#-gitlab--create_initial_root_token)
* [`initial_root_token`](#-gitlab--initial_root_token)
* [`initial_root_token_ttl_minutes`](#-gitlab--initial_root_token_ttl_minutes)
* [`consul`](#-gitlab--consul)
* [`custom_hooks_dir`](#-gitlab--custom_hooks_dir)
* [`system_hooks_dir`](#-gitlab--system_hooks_dir)
Expand Down Expand Up @@ -919,6 +928,30 @@ Password for the gitlab-consul database user in the pgbouncer database

Default value: `undef`

##### <a name="-gitlab--create_initial_root_token"></a>`create_initial_root_token`

Data type: `Boolean`

Whether to create an initial root token. If set to true and initial_root_token is undef, a random token string will be generated.

Default value: `false`

##### <a name="-gitlab--initial_root_token"></a>`initial_root_token`

Data type: `Optional[Sensitive[String[1]]]`

Preset a root token to allow API usage immediately.

Default value: `undef`

##### <a name="-gitlab--initial_root_token_ttl_minutes"></a>`initial_root_token_ttl_minutes`

Data type: `Integer[0]`

Initial root token time to live (in minutes).

Default value: `60`

##### <a name="-gitlab--consul"></a>`consul`

Data type: `Optional[Hash]`
Expand Down
105 changes: 105 additions & 0 deletions files/gitlab_api_token_renewer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env ruby

require 'net/http'
require 'json'
require 'time'
require 'uri'
require 'tempfile'

class GitlabApiTokenRenewer
def initialize
@api_url = ENV.fetch('GITLAB_API_URL', 'http://localhost')
@token_file = ENV.fetch('GITLAB_API_TOKEN_FILE', '/var/opt/gitlab/.tokens/puppet_token')
@token_renew_days = ENV.fetch('GITLAB_API_TOKEN_RENEW_DAYS', '7').to_i
@new_token_ttl_days = ENV.fetch('GITLAB_API_NEW_TOKEN_TTL_DAYS', '30').to_i
@token = File.read(@token_file).strip

uri = URI(@api_url)
@http = Net::HTTP.new(uri.host, uri.port)
@http.use_ssl = uri.scheme == 'https'
end

def write_token
f = Tempfile.create('.tkn', File.dirname(@token_file))
f.write(token)
f.flush
f.close
File.rename(f, @token_file)
end

def api_request(method, endpoint, body = nil)
request_class = case method.downcase
when :get then Net::HTTP::Get
when :post then Net::HTTP::Post
else raise "Unsupported HTTP method"
end

request = request_class.new(uri)
request['Authorization'] = "Bearer #{token}"
request['Content-Type'] = 'application/json' if body
request.body = body.to_json if body

@http.request(request)
end

def get_current_token_info
response = api_request(:get, 'personal_access_tokens/self')

case response
when Net::HTTPSuccess
JSON.parse(response.body)
when Net::HTTPUnauthorized
abort "Token is invalid, revoked, or expired."
else
abort "Failed to get token info: #{response.code} #{response.body}"
end
end

def rotate_current_token(new_expiry = nil)
payload = {}
payload[:expires_at] = new_expiry if new_expiry

response = api_request(:post, 'personal_access_tokens/self/rotate', payload)

case response
when Net::HTTPSuccess
@token = JSON.parse(response.body)['token']
when Net::HTTPUnauthorized
abort "Token cannot be rotated (revoked, expired, or invalid)."
when Net::HTTPForbidden
abort "Token lacks permission to rotate (needs 'api' or 'self_rotate' scope)."
else
abort "Rotation failed: #{response.code} #{response.body}"
end
end

def run
info = get_current_token_info
expires_at_str = info['expires_at']

if expires_at_str.nil?
warn "Token has no expiration."
else
expires_at = Time.parse(expires_at_str).utc
threshold = Time.now.utc + (@token_renew_days * 86400)
if expires_at > threshold
puts "Token expires on #{expires_at}, still valid. No rotation needed."
exit 0
end
puts "Token expires on #{expires_at}, rotating..."
end

new_expiry = (Time.now + 30 * 24 * 60 * 60).strftime('%Y-%m-%d')
rotate_current_token(new_expiry)
puts "Token rotated in GitLab."

write_token
puts "New token written to #{@token_file}."

puts "Rotation complete."
end
end

if __FILE__ == $0
GitlabApiTokenRenewer.new.run
end
10 changes: 9 additions & 1 deletion manifests/init.pp
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,17 @@
# @param backup_cron_minute The minute when to run the daily backup cron job
# @param backup_cron_hour The hour when to run the daily backup cron job
# @param backup_cron_skips Array of items to skip valid values: db, uploads, repositories, builds, artifacts, lfs, registry, pages
# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers.
# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers.
# @param package_name The internal packaging system's name for the package. This name will automatically be changed by the gitlab::edition parameter. Can be overridden for the purposes of installing custom compiled version of gitlab-omnibus.
# @param manage_package Should the GitLab package be managed?
# @param repository_configuration A hash of repository types and attributes for configuraiton the gitlab package repositories. See docs in README.md
# @param manage_omnibus_repository Set to false if you wish to manage gitlab without configuring the package repository
# @param pgpass_file_location Path to location of .pgpass file used by consul to authenticate with pgbouncer database
# @param pgpass_file_ensure Create .pgpass file for pgbouncer authentication. When set to present requires valid value for pgbouncer_password.
# @param pgbouncer_password Password for the gitlab-consul database user in the pgbouncer database
# @param manage_api_token Whether to manage the API token. This token belongs to the root Gitlab user.
# @param api_token_dir Where to store the API token generated.
# @param api_token_ttl_days API token time to live in days.
class gitlab (
Hash $repository_configuration,
# package configuration
Expand Down Expand Up @@ -224,6 +227,9 @@
Optional[Hash] $gitlab_workhorse = undef,
Optional[Hash] $user = undef,
Optional[Hash] $web_server = undef,
Boolean $manage_api_token = false,
Stdlib::Absolutepath $api_token_file = '/var/opt/gitlab/.tokens/puppet_token',
Integer[0] $api_token_ttl_days = 30,
Boolean $backup_cron_enable = false,
Integer[0,59] $backup_cron_minute = 0,
Integer[0,23] $backup_cron_hour = 2,
Expand All @@ -238,11 +244,13 @@
contain gitlab::omnibus_config
contain gitlab::install
contain gitlab::service
contain gitlab::initial_root_token

Class['gitlab::host_config']
-> Class['gitlab::omnibus_config']
-> Class['gitlab::install']
-> Class['gitlab::service']
-> Class['gitlab::initial_root_token']

$custom_hooks.each |$name, $options| {
gitlab::custom_hook { $name:
Expand Down
49 changes: 49 additions & 0 deletions manifests/initial_root_token.pp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# @summary Manages initial root token
#
# **NOTE** This hack allows to use the gitlab instance via API immediately.
# While this way is quite convenient, it cannot be called a good one..
# Use it at your own risk!
#
# Remove the /etc/gitlab/initial_root_token file to regenerate the token in a
# next Puppet run.
#
# @see https://docs.gitlab.com/administration/operations/rails_console/#using-the-rails-runner
# @see https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token-programmatically
#
# @api private
class gitlab::initial_root_token {
$api_token_file = $gitlab::api_token_file
$script_path = '/etc/gitlab/create_initial_root_token.rb'

if $gitlab::create_initial_root_token {
$script_ensure = 'file'
$script_content = epp('gitlab/create_initial_root_token.rb.epp',
token => $gitlab::initial_root_token,
token_ttl_minutes => $gitlab::initial_root_token_ttl_minutes,
token_file_path => $token_file_path,
)

# Execute after the script is created, but only if token is managed
exec { 'create_initial_root_token':
command => "/usr/bin/gitlab-rails runner '${script_path}'",
creates => $token_file_path,
require => File[$script_path],
}
} else {
$script_ensure = 'absent'
$script_content = undef

# Ensure there is no token file left if it was created before
file { $token_file_path:
ensure => 'absent',
}
}

file { $script_path:
ensure => $script_ensure,
owner => 'root',
group => 'git', # gitlab-rails runner executes this script as 'git' user
mode => '0640',
content => $script_content,
}
}
30 changes: 29 additions & 1 deletion spec/classes/init_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
it { is_expected.to contain_class('gitlab::host_config').that_comes_before('Class[gitlab::install]') }
it { is_expected.to contain_class('gitlab::omnibus_config').that_comes_before('Class[gitlab::install]') }
it { is_expected.to contain_class('gitlab::install').that_comes_before('Class[gitlab::service]') }
it { is_expected.to contain_class('gitlab::service') }
it { is_expected.to contain_class('gitlab::service').that_comes_before('Class[gitlab::initial_root_token]') }
it { is_expected.to contain_class('gitlab::initial_root_token') }
it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_ensure('absent') }
it { is_expected.to contain_file('/etc/gitlab/initial_root_token').with_ensure('absent') }
it { is_expected.not_to contain_exec('create_initial_root_token') }
it { is_expected.to contain_exec('gitlab_reconfigure').that_subscribes_to('Class[gitlab::omnibus_config]') }
it { is_expected.to contain_file('/etc/gitlab/gitlab.rb') }
it { is_expected.to contain_package('gitlab-omnibus').with_ensure('installed').with_name('gitlab-ce') }
Expand Down Expand Up @@ -511,6 +515,30 @@
is_expected.to contain_package('gitlab-omnibus').with('ensure' => '16.10.3-ce.0', 'name' => 'gitlab-ce', 'mark' => 'hold')
}
end
describe 'create_intial_root_token' do
let(:params) { { create_initial_root_token: true } }

it do
is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').
with_content(%r{^token = nil$}).
with_content(%r{^token_ttl_minutes = 60$}).
with_content(%r{^token_file_path = '/etc/gitlab/initial_root_token'$})
end
it { is_expected.to contain_exec('create_initial_root_token').with_creates('/etc/gitlab/initial_root_token') }
it { is_expected.not_to contain_file('/etc/gitlab/initial_root_token') } # This file is managed only if create_initial_root_token is false

describe 'initial_root_token' do
let(:params) { super().merge(initial_root_token: sensitive('foobarbaz')) }

it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token = 'foobarbaz'$}) }
end

describe 'initial_root_token_ttl_minutes' do
let(:params) { super().merge(initial_root_token_ttl_minutes: 123) }

it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token_ttl_minutes = 123$}) }
end
end
end
end
end
Expand Down
24 changes: 24 additions & 0 deletions templates/create_initial_root_token.rb.epp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<%-|
Optional[Sensitive[String[1]]] $token,
Integer[0] $token_ttl_minutes,
Stdlib::AbsolutePath $token_file_path,
|-%>
# This script should be executed with 'gitlab-rails runner' command.
#
# This scripts creates an initial root token and stores it to the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about a shebang line?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's executed through the Gitlab Rails Runner, so it shouldn't need one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add comment there to reduce confusion :)

# <%= $token_file_path %> file.
token = <%= $token.then |$x| { "'${$x.unwrap}'" }.lest || { nil } %>
token_ttl_minutes = <%= $token_ttl_minutes %>
token_file_path = '<%= $token_file_path %>'

require 'securerandom'
token_value = token || 'glpat-' + SecureRandom.alphanumeric(20)

t = User.find(1).personal_access_tokens.create(
scopes: [:api],
name: 'Gitlab Puppet module initial root token',
expires_at: token_ttl_minutes.minutes.from_now,
)
t.set_token(token_value)
t.save!
File.write(token_file_path, token_value, perm: 0600)
Loading