Commit 48e7ce95 authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Thong Kuah

Add table for grafana api tokens

In preparation for supporting the ability to embed Grafana metrics
in GitLab Flavored Markdown, we need to allow a way for users to
store credentials for us to access the HTTP API for their grafana
instance. We expect users to save two key attributes:
an authorization token and the url of their grafana instance.

This work adds the table and model for storing this auth config.
parent 9ff8b86e
...@@ -63,7 +63,9 @@ module Projects ...@@ -63,7 +63,9 @@ module Projects
:api_host, :api_host,
:token, :token,
project: [:slug, :name, :organization_slug, :organization_name] project: [:slug, :name, :organization_slug, :organization_name]
] ],
grafana_integration_attributes: [:token, :grafana_url]
} }
end end
end end
......
...@@ -354,6 +354,14 @@ module ProjectsHelper ...@@ -354,6 +354,14 @@ module ProjectsHelper
@project.metrics_setting_external_dashboard_url @project.metrics_setting_external_dashboard_url
end end
def grafana_integration_url
@project.grafana_integration&.grafana_url
end
def grafana_integration_token
@project.grafana_integration&.token
end
private private
def get_project_nav_tabs(project, current_user) def get_project_nav_tabs(project, current_user)
......
# frozen_string_literal: true
class GrafanaIntegration < ApplicationRecord
belongs_to :project
attr_encrypted :token,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32
validates :grafana_url,
length: { maximum: 1024 },
addressable_url: { enforce_sanitization: true, ascii_only: true }
validates :token, :project, presence: true
end
...@@ -195,6 +195,7 @@ class Project < ApplicationRecord ...@@ -195,6 +195,7 @@ class Project < ApplicationRecord
has_one :project_repository, inverse_of: :project has_one :project_repository, inverse_of: :project
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
has_one :grafana_integration, inverse_of: :project
# Merge Requests for target project should be removed with it # Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
...@@ -311,6 +312,7 @@ class Project < ApplicationRecord ...@@ -311,6 +312,7 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
delegate :name, to: :owner, allow_nil: true, prefix: true delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true delegate :members, to: :team, prefix: true
......
...@@ -12,7 +12,9 @@ module Projects ...@@ -12,7 +12,9 @@ module Projects
private private
def project_update_params def project_update_params
error_tracking_params.merge(metrics_setting_params) error_tracking_params
.merge(metrics_setting_params)
.merge(grafana_integration_params)
end end
def metrics_setting_params def metrics_setting_params
...@@ -44,6 +46,14 @@ module Projects ...@@ -44,6 +46,14 @@ module Projects
} }
} }
end end
def grafana_integration_params
return {} unless attrs = params[:grafana_integration_attributes]
destroy = attrs[:grafana_url].blank? && attrs[:token].blank?
{ grafana_integration_attributes: attrs.merge(_destroy: destroy) }
end
end end
end end
end end
......
---
title: Create table for grafana api token for metrics embeds
merge_request: 17234
author:
type: added
...@@ -16,6 +16,9 @@ en: ...@@ -16,6 +16,9 @@ en:
api_url: "Sentry API URL" api_url: "Sentry API URL"
project/metrics_setting: project/metrics_setting:
external_dashboard_url: "External dashboard URL" external_dashboard_url: "External dashboard URL"
project/grafana_integration:
token: "Grafana HTTP API Token"
grafana_url: "Grafana API URL"
views: views:
pagination: pagination:
previous: "Prev" previous: "Prev"
......
# frozen_string_literal: true
class CreateGrafanaIntegrations < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :grafana_integrations do |t|
t.references :project, index: true, foreign_key: { on_delete: :cascade }, unique: true, null: false
t.timestamps_with_timezone null: false
t.string :encrypted_token, limit: 255, null: false
t.string :encrypted_token_iv, limit: 255, null: false
t.string :grafana_url, null: false, limit: 1024
end
end
end
...@@ -1704,6 +1704,16 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do ...@@ -1704,6 +1704,16 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do
t.index ["project_id"], name: "index_gpg_signatures_on_project_id" t.index ["project_id"], name: "index_gpg_signatures_on_project_id"
end end
create_table "grafana_integrations", force: :cascade do |t|
t.bigint "project_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "encrypted_token", limit: 255, null: false
t.string "encrypted_token_iv", limit: 255, null: false
t.string "grafana_url", limit: 1024, null: false
t.index ["project_id"], name: "index_grafana_integrations_on_project_id"
end
create_table "group_custom_attributes", id: :serial, force: :cascade do |t| create_table "group_custom_attributes", id: :serial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false t.datetime_with_timezone "updated_at", null: false
...@@ -3997,6 +4007,7 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do ...@@ -3997,6 +4007,7 @@ ActiveRecord::Schema.define(version: 2019_09_27_074328) do
add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify
add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify
add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "gpg_signatures", "projects", on_delete: :cascade
add_foreign_key "grafana_integrations", "projects", on_delete: :cascade
add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade
add_foreign_key "import_export_uploads", "projects", on_delete: :cascade add_foreign_key "import_export_uploads", "projects", on_delete: :cascade
......
...@@ -125,6 +125,11 @@ module Gitlab ...@@ -125,6 +125,11 @@ module Gitlab
# If the addr can't be resolved or the url is invalid (i.e http://1.1.1.1.1) # If the addr can't be resolved or the url is invalid (i.e http://1.1.1.1.1)
# we block the url # we block the url
raise BlockedUrlError, "Host cannot be resolved or invalid" raise BlockedUrlError, "Host cannot be resolved or invalid"
rescue ArgumentError => error
# Addrinfo.getaddrinfo errors if the domain exceeds 1024 characters.
raise unless error.message.include?('hostname too long')
raise BlockedUrlError, "Host is too long (maximum is 1024 characters)"
end end
def validate_local_request( def validate_local_request(
......
...@@ -180,6 +180,21 @@ describe Projects::Settings::OperationsController do ...@@ -180,6 +180,21 @@ describe Projects::Settings::OperationsController do
end end
end end
context 'grafana integration' do
describe 'PATCH #update' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: 'https://grafana.gitlab.com',
token: 'eyJrIjoicDRlRTREdjhhOEZ5WjZPWXUzazJOSW0zZHJUejVOd3IiLCJuIjoiVGVzdCBLZXkiLCJpZCI6MX0='
}
}
end
it_behaves_like 'PATCHable'
end
end
private private
def project_params(project, params = {}) def project_params(project, params = {})
......
# frozen_string_literal: true
FactoryBot.define do
factory :grafana_integration, class: GrafanaIntegration do
project
grafana_url { 'https://grafana.com' }
token { SecureRandom.hex(10) }
end
end
...@@ -902,4 +902,40 @@ describe ProjectsHelper do ...@@ -902,4 +902,40 @@ describe ProjectsHelper do
end end
end end
end end
describe '#grafana_integration_url' do
let(:project) { create(:project) }
before do
helper.instance_variable_set(:@project, project)
end
subject { helper.grafana_integration_url }
it { is_expected.to eq(nil) }
context 'grafana integration exists' do
let!(:grafana_integration) { create(:grafana_integration, project: project) }
it { is_expected.to eq(grafana_integration.grafana_url) }
end
end
describe '#grafana_integration_token' do
let(:project) { create(:project) }
before do
helper.instance_variable_set(:@project, project)
end
subject { helper.grafana_integration_token }
it { is_expected.to eq(nil) }
context 'grafana integration exists' do
let!(:grafana_integration) { create(:grafana_integration, project: project) }
it { is_expected.to eq(grafana_integration.token) }
end
end
end end
...@@ -411,6 +411,7 @@ project: ...@@ -411,6 +411,7 @@ project:
- external_pull_requests - external_pull_requests
- pages_metadatum - pages_metadatum
- alerts_service - alerts_service
- grafana_integration
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
...@@ -62,6 +62,14 @@ describe Gitlab::UrlBlocker do ...@@ -62,6 +62,14 @@ describe Gitlab::UrlBlocker do
expect { subject }.to raise_error(described_class::BlockedUrlError) expect { subject }.to raise_error(described_class::BlockedUrlError)
end end
end end
context 'when domain is too long' do
let(:import_url) { 'https://example' + 'a' * 1024 + '.com' }
it 'raises an error' do
expect { subject }.to raise_error(described_class::BlockedUrlError)
end
end
end end
context 'when the URL hostname is an IP address' do context 'when the URL hostname is an IP address' do
......
# frozen_string_literal: true
require 'spec_helper'
describe GrafanaIntegration do
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:token) }
it 'disallows invalid urls for grafana_url' do
unsafe_url = %{https://replaceme.com/'><script>alert(document.cookie)</script>}
non_ascii_url = 'http://gitlab.com/api/0/projects/project1/something€'
blank_url = ''
excessively_long_url = 'https://grafan' + 'a' * 1024 + '.com'
is_expected.not_to allow_values(
unsafe_url,
non_ascii_url,
blank_url,
excessively_long_url
).for(:grafana_url)
end
it 'allows valid urls for grafana_url' do
external_url = 'http://grafana.com/'
internal_url = 'http://192.168.1.1'
is_expected.to allow_value(
external_url,
internal_url
).for(:grafana_url)
end
end
end
...@@ -170,5 +170,61 @@ describe Projects::Operations::UpdateService do ...@@ -170,5 +170,61 @@ describe Projects::Operations::UpdateService do
expect(project.reload.name).to eq(original_name) expect(project.reload.name).to eq(original_name)
end end
end end
context 'grafana integration' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: 'http://new.grafana.com',
token: 'VerySecureToken='
}
}
end
context 'without existing grafana integration' do
it 'creates an integration' do
expect(result[:status]).to eq(:success)
expected_attrs = params[:grafana_integration_attributes]
integration = project.reload.grafana_integration
expect(integration.grafana_url).to eq(expected_attrs[:grafana_url])
expect(integration.token).to eq(expected_attrs[:token])
end
end
context 'with an existing grafana integration' do
before do
create(:grafana_integration, project: project)
end
it 'updates the settings' do
expect(result[:status]).to eq(:success)
expected_attrs = params[:grafana_integration_attributes]
integration = project.reload.grafana_integration
expect(integration.grafana_url).to eq(expected_attrs[:grafana_url])
expect(integration.token).to eq(expected_attrs[:token])
end
context 'with all grafana attributes blank in params' do
let(:params) do
{
grafana_integration_attributes: {
grafana_url: '',
token: ''
}
}
end
it 'destroys the metrics_setting entry in DB' do
expect(result[:status]).to eq(:success)
expect(project.reload.grafana_integration).to be_nil
end
end
end
end
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